Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions client/src/core/DebugPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface DebugPanelConfig {
instancedMeshCount: number;
drawCallsSaved: number;
attributeElementsUpdated: number;
attributeUploadsSkipped: number;
};
sceneUI: {
count: number;
Expand Down Expand Up @@ -139,6 +140,7 @@ export default class DebugPanel {
instancedMeshCount: 0,
drawCallsSaved: 0,
attributeElementsUpdated: 0,
attributeUploadsSkipped: 0,
},
sceneUI: {
count: 0,
Expand Down Expand Up @@ -249,6 +251,7 @@ export default class DebugPanel {
gltfFolder.add(this._config.gltf, 'instancedMeshCount').name('Instanced Meshes');
gltfFolder.add(this._config.gltf, 'drawCallsSaved').name('Draw Calls Saved');
gltfFolder.add(this._config.gltf, 'attributeElementsUpdated').name('Attr El Update');
gltfFolder.add(this._config.gltf, 'attributeUploadsSkipped').name('Attr Uploads Skip');

// Scene UI Stats panel
const sceneUIFolder = this._gui.addFolder('Scene UI');
Expand Down Expand Up @@ -380,6 +383,7 @@ export default class DebugPanel {
this._config.gltf.instancedMeshCount = GLTFStats.instancedMeshCount;
this._config.gltf.drawCallsSaved = GLTFStats.drawCallsSaved;
this._config.gltf.attributeElementsUpdated = GLTFStats.attributeElementsUpdated;
this._config.gltf.attributeUploadsSkipped = GLTFStats.attributeUploadsSkipped;
}

private _updateSceneUIStats(): void {
Expand Down
240 changes: 164 additions & 76 deletions client/src/gltf/GLTFManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,6 @@ const UNIFORM_RAW_AMBIENT_LIGHT_COLOR = 'rawAmbientLightColor';
const UNIFORM_AMBIENT_LIGHT_INTENSITY = 'ambientLightIntensity';

// Working variables
const attributes: InstancedBufferAttribute[] = [];
const clonedMeshArray: Mesh[] = [];
const opaqueClonedMeshes: Mesh[] = [];
const transparentClonedMeshes: Mesh[] = [];
const usedColorTextureSet: Set<Texture> = new Set();
Expand Down Expand Up @@ -598,6 +596,22 @@ type SourceMeshAttributeCounters = {
nonDefaultEmissive: number;
};

type CachedInstanceState = {
lastIndex: number;
lastInstancedMesh: InstancedMeshEx | null;
matrixSnapshot: Float32Array;
skyLight: number;
colorR: number;
colorG: number;
colorB: number;
opacity: number;
lightLevel: number;
emissiveR: number;
emissiveG: number;
emissiveB: number;
emissiveIntensity: number;
};

// Necessary to keep track of various information for resource management.
// TODO: The current resource management might be more complex than necessary.
// Simplify if possible.
Expand Down Expand Up @@ -635,6 +649,7 @@ export default class GLTFManager {
private _gltfToEntry: Map<Promise<GLTF> | GLTF, GLTFEntry> = new Map();
private _sourceMeshToEntry: Map<Mesh, GLTFEntry> = new Map();
private _clonedMeshToSourceMesh: Map<Mesh, Mesh> = new Map();
private _instanceStateCache: WeakMap<Mesh, CachedInstanceState> = new WeakMap();

constructor(game: Game) {
this._game = game;
Expand Down Expand Up @@ -1235,93 +1250,135 @@ export default class GLTFManager {
console.warn(`GLTFManager._processClonedMeshes(): Client implementation error. counters not found for sourceMesh.`);
}

// Determine which attributes need to be updated based on counters
// If counters not found, use true (no optimization but renders correctly)
const needsColorAttribute = counters ? counters.nonDefaultColor > 0 : true;
const needsOpacityAttribute = counters ? counters.nonDefaultOpacity > 0 : true;
const needsLightLevelAttribute = this._game.entityManager.hasLightLevelVolumeUpdatedOnce;
const needsEmissiveAttribute = counters ? counters.nonDefaultEmissive > 0 : true;

// Select appropriate InstancedMesh based on cloned mesh count
let instancedMeshIndex = 0;
while (clonedMeshes.length > instancedMeshPairs[instancedMeshIndex].opaque.instanceMatrix.count) {
instancedMeshIndex++;
}
const targetPair = instancedMeshPairs[instancedMeshIndex];
const instancedMesh = isTransparent ? targetPair.transparent : targetPair.opaque;

// Pre-fetch attribute references for the single-pass loop
const skyLightAttr = instancedMesh.geometry.getAttribute(INSTANCE_SKY_LIGHT_ATTRIBUTE)!;
const opacityAttr = needsOpacityAttribute ? instancedMesh.geometry.getAttribute(INSTANCE_OPACITY_ATTRIBUTE)! : null;
const lightLevelAttr = needsLightLevelAttribute ? instancedMesh.geometry.getAttribute(INSTANCE_LIGHT_LEVEL_ATTRIBUTE)! : null;
const emissiveAttr = needsEmissiveAttribute ? instancedMesh.geometry.getAttribute(INSTANCE_EMISSIVE_ATTRIBUTE)! : null;

let anyMatrixDirty = false;
let anySkyLightDirty = false;
let anyColorDirty = false;
let anyOpacityDirty = false;
let anyLightLevelDirty = false;
let anyEmissiveDirty = false;

let index = 0;

for (const clonedMesh of clonedMeshes) {
// Accessing all cloned meshes every animation frame, copying necessary data, and transferring
// it to the WebGL buffer may be costly for both the CPU and GPU. However, since the rendering
// cost reduction currently provides a much greater performance benefit, this is not a concern
// for now. If this cost becomes an issue, the following optimizations could be considered:
// * Somehow introduce a mechanism to allow the instance attribute array is treated as a property
// of cloned meshes.
// * Transfer instance attribute values to the WebGL buffer only when changes occur.

const material = clonedMesh.material as EmissiveMeshBasicMaterial;

if (material.map) {
usedColorTextureSet.add(material.map);
}

// Assumes that the InstancedMesh is directly under the Scene and
// its World Matrix is an Identity Matrix.
instancedMesh.setMatrixAt(index, clonedMesh.matrixWorld);
instancedMesh.geometry.getAttribute(INSTANCE_SKY_LIGHT_ATTRIBUTE)!.setX(
index,
Entity.getEffectiveSkyLight(this._game, clonedMesh),
);
let cached = this._instanceStateCache.get(clonedMesh);
const isNew = !cached;

if (!cached) {
cached = {
lastIndex: -1,
lastInstancedMesh: null,
matrixSnapshot: new Float32Array(16),
skyLight: NaN,
colorR: NaN, colorG: NaN, colorB: NaN,
opacity: NaN,
lightLevel: NaN,
emissiveR: NaN, emissiveG: NaN, emissiveB: NaN, emissiveIntensity: NaN,
};
this._instanceStateCache.set(clonedMesh, cached);
}

clonedMeshArray[index] = clonedMesh;
index++;
}
// Force full write when this mesh is new, moved to a different instance
// slot, or targets a different InstancedMesh (e.g. after a resize).
const forceUpdate = isNew || cached.lastIndex !== index || cached.lastInstancedMesh !== instancedMesh;

// --- Matrix (always needed) ---
const elements = clonedMesh.matrixWorld.elements;
let matrixChanged = forceUpdate;
if (!matrixChanged) {
const snap = cached.matrixSnapshot;
for (let j = 0; j < 16; j++) {
if (elements[j] !== snap[j]) { matrixChanged = true; break; }
}
}
if (matrixChanged) {
instancedMesh.setMatrixAt(index, clonedMesh.matrixWorld);
cached.matrixSnapshot.set(elements);
anyMatrixDirty = true;
}

if (needsColorAttribute) {
for (let i = 0; i < index; i++) {
const clonedMesh = clonedMeshArray[i];
const material = clonedMesh.material as EmissiveMeshBasicMaterial;
instancedMesh.setColorAt(i, material.color);
// --- Sky light (always needed) ---
const skyLight = Entity.getEffectiveSkyLight(this._game, clonedMesh);
if (forceUpdate || skyLight !== cached.skyLight) {
skyLightAttr.setX(index, skyLight);
cached.skyLight = skyLight;
anySkyLightDirty = true;
}
}

if (needsOpacityAttribute) {
const opacityAttribute = instancedMesh.geometry.getAttribute(INSTANCE_OPACITY_ATTRIBUTE)!;
for (let i = 0; i < index; i++) {
const clonedMesh = clonedMeshArray[i];
const material = clonedMesh.material as EmissiveMeshBasicMaterial;
opacityAttribute.setX(i, material.opacity);
// --- Color ---
if (needsColorAttribute) {
const { r, g, b } = material.color;
if (forceUpdate || r !== cached.colorR || g !== cached.colorG || b !== cached.colorB) {
instancedMesh.setColorAt(index, material.color);
cached.colorR = r;
cached.colorG = g;
cached.colorB = b;
anyColorDirty = true;
}
}
}

if (needsLightLevelAttribute) {
const lightLevelAttribute = instancedMesh.geometry.getAttribute(INSTANCE_LIGHT_LEVEL_ATTRIBUTE)!;
for (let i = 0; i < index; i++) {
const clonedMesh = clonedMeshArray[i];
lightLevelAttribute.setX(i, Entity.getEffectiveLightLevel(this._game, clonedMesh));
// --- Opacity ---
if (needsOpacityAttribute) {
if (forceUpdate || material.opacity !== cached.opacity) {
opacityAttr!.setX(index, material.opacity);
cached.opacity = material.opacity;
anyOpacityDirty = true;
}
}
}

if (needsEmissiveAttribute) {
const emissiveAttribute = instancedMesh.geometry.getAttribute(INSTANCE_EMISSIVE_ATTRIBUTE)!;
for (let i = 0; i < index; i++) {
const clonedMesh = clonedMeshArray[i];
const material = clonedMesh.material as EmissiveMeshBasicMaterial;
// vec4: rgb = emissive color, a = emissive intensity
emissiveAttribute.setXYZW(
i,
material.customEmissive.r,
material.customEmissive.g,
material.customEmissive.b,
material.customEmissiveIntensity,
);
// --- Light level ---
if (needsLightLevelAttribute) {
const lightLevel = Entity.getEffectiveLightLevel(this._game, clonedMesh);
if (forceUpdate || lightLevel !== cached.lightLevel) {
lightLevelAttr!.setX(index, lightLevel);
cached.lightLevel = lightLevel;
anyLightLevelDirty = true;
}
}

// --- Emissive ---
if (needsEmissiveAttribute) {
const { r: er, g: eg, b: eb } = material.customEmissive;
const ei = material.customEmissiveIntensity;
if (forceUpdate || er !== cached.emissiveR || eg !== cached.emissiveG || eb !== cached.emissiveB || ei !== cached.emissiveIntensity) {
emissiveAttr!.setXYZW(index, er, eg, eb, ei);
cached.emissiveR = er;
cached.emissiveG = eg;
cached.emissiveB = eb;
cached.emissiveIntensity = ei;
anyEmissiveDirty = true;
}
}

cached.lastIndex = index;
cached.lastInstancedMesh = instancedMesh;
index++;
}

instancedMesh.count = index;
clonedMeshArray.length = 0;

if (index > 0) {
this._game.renderer.addToScene(instancedMesh);
Expand All @@ -1345,40 +1402,71 @@ export default class GLTFManager {
useInstancedTexture = true;
}

attributes.push(instancedMesh.instanceMatrix);
// Only mark attributes as needing GPU upload when their data actually changed.
// This avoids costly bufferSubData calls for instance data that is identical
// to the previous frame (e.g. stationary entities, unchanged lighting).

if (needsColorAttribute) {
attributes.push(instancedMesh.instanceColor!);
if (anyMatrixDirty) {
instancedMesh.instanceMatrix.clearUpdateRanges();
instancedMesh.instanceMatrix.addUpdateRange(0, index * 16);
instancedMesh.instanceMatrix.needsUpdate = true;
GLTFStats.attributeElementsUpdated += index * 16;
}

if (needsOpacityAttribute) {
attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_OPACITY_ATTRIBUTE)! as InstancedBufferAttribute);
if (anyColorDirty && needsColorAttribute) {
instancedMesh.instanceColor!.clearUpdateRanges();
instancedMesh.instanceColor!.addUpdateRange(0, index * 3);
(instancedMesh.instanceColor! as InstancedBufferAttribute).needsUpdate = true;
GLTFStats.attributeElementsUpdated += index * 3;
}

if (needsLightLevelAttribute) {
attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_LIGHT_LEVEL_ATTRIBUTE)! as InstancedBufferAttribute);
if (anyOpacityDirty && needsOpacityAttribute) {
const attr = opacityAttr! as InstancedBufferAttribute;
attr.clearUpdateRanges();
attr.addUpdateRange(0, index * attr.itemSize);
attr.needsUpdate = true;
GLTFStats.attributeElementsUpdated += index * attr.itemSize;
}

attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_SKY_LIGHT_ATTRIBUTE)! as InstancedBufferAttribute);
if (anySkyLightDirty) {
const attr = skyLightAttr as InstancedBufferAttribute;
attr.clearUpdateRanges();
attr.addUpdateRange(0, index * attr.itemSize);
attr.needsUpdate = true;
GLTFStats.attributeElementsUpdated += index * attr.itemSize;
}

if (needsEmissiveAttribute) {
attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_EMISSIVE_ATTRIBUTE)! as InstancedBufferAttribute);
if (anyLightLevelDirty && needsLightLevelAttribute) {
const attr = lightLevelAttr! as InstancedBufferAttribute;
attr.clearUpdateRanges();
attr.addUpdateRange(0, index * attr.itemSize);
attr.needsUpdate = true;
GLTFStats.attributeElementsUpdated += index * attr.itemSize;
}

if (useInstancedTexture) {
attributes.push(instancedMesh.geometry.getAttribute(INSTANCE_MAP_INDEX_ATTRIBUTE)! as InstancedBufferAttribute);
if (anyEmissiveDirty && needsEmissiveAttribute) {
const attr = emissiveAttr! as InstancedBufferAttribute;
attr.clearUpdateRanges();
attr.addUpdateRange(0, index * attr.itemSize);
attr.needsUpdate = true;
GLTFStats.attributeElementsUpdated += index * attr.itemSize;
}

for (const attribute of attributes) {
attribute.clearUpdateRanges();
attribute.addUpdateRange(0, index * attribute.itemSize);
attribute.needsUpdate = true;
GLTFStats.attributeElementsUpdated += index * attribute.itemSize;
if (useInstancedTexture) {
const attr = instancedMesh.geometry.getAttribute(INSTANCE_MAP_INDEX_ATTRIBUTE)! as InstancedBufferAttribute;
attr.clearUpdateRanges();
attr.addUpdateRange(0, index * attr.itemSize);
attr.needsUpdate = true;
GLTFStats.attributeElementsUpdated += index * attr.itemSize;
}
attributes.length = 0;

// Since the Frustum is slightly enlarged, this measurement is not
// entirely accurate, but it should be taken only as a rough reference.
if (!anyMatrixDirty) GLTFStats.attributeUploadsSkipped++;
if (!anySkyLightDirty) GLTFStats.attributeUploadsSkipped++;
if (needsColorAttribute && !anyColorDirty) GLTFStats.attributeUploadsSkipped++;
if (needsOpacityAttribute && !anyOpacityDirty) GLTFStats.attributeUploadsSkipped++;
if (needsLightLevelAttribute && !anyLightLevelDirty) GLTFStats.attributeUploadsSkipped++;
if (needsEmissiveAttribute && !anyEmissiveDirty) GLTFStats.attributeUploadsSkipped++;

GLTFStats.drawCallsSaved += index - 1;
}
usedColorTextureSet.clear();
Expand Down
2 changes: 2 additions & 0 deletions client/src/gltf/GLTFStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ export default class GLTFStats {
public static instancedMeshCount: number = 0;
public static drawCallsSaved: number = 0;
public static attributeElementsUpdated: number = 0;
public static attributeUploadsSkipped: number = 0;

public static reset(): void {
GLTFStats.drawCallsSaved = 0;
GLTFStats.attributeElementsUpdated = 0;
GLTFStats.attributeUploadsSkipped = 0;
}
}