diff --git a/src/scene/gsplat-unified/gsplat-compute-local-renderer.js b/src/scene/gsplat-unified/gsplat-compute-local-renderer.js index c5ff6847a30..24567ec4730 100644 --- a/src/scene/gsplat-unified/gsplat-compute-local-renderer.js +++ b/src/scene/gsplat-unified/gsplat-compute-local-renderer.js @@ -40,6 +40,7 @@ import { GSplatTileComposite } from './gsplat-tile-composite.js'; import { GSplatLocalDispatchSet } from './gsplat-local-dispatch-set.js'; import { ALPHA_VISIBILITY_THRESHOLD, CACHE_STRIDE } from './constants.js'; import computeSplatSource from '../shader-lib/wgsl/chunks/gsplat/vert/gsplatComputeSplat.js'; +import gsplatModifyDefaultSource from '../shader-lib/wgsl/chunks/gsplat/vert/gsplatModify.js'; /** * @import { GraphNode } from '../graph-node.js' @@ -1281,6 +1282,9 @@ class GSplatComputeLocalRenderer extends GSplatRenderer { cincludes.set('gsplatComputeSplatCS', computeSplatSource); cincludes.set('gsplatFormatDeclCS', wbFormat.getComputeInputDeclarations(fixedBindings.length)); cincludes.set('gsplatFormatReadCS', wbFormat.getReadCode()); + // default (no-op) modify hooks required by projectSplatCommon; the compute renderer does not + // expose render-stage customization, so the dummy chunk is sufficient (no gsplatHelpersVS). + cincludes.set('gsplatModifyVS', gsplatModifyDefaultSource); cincludes.set('gsplatProjectCommonCS', computeGsplatProjectCommonSource); const cdefines = new Map(); diff --git a/src/scene/gsplat-unified/gsplat-manager.js b/src/scene/gsplat-unified/gsplat-manager.js index db61aaa3ed5..c574f3a39d4 100644 --- a/src/scene/gsplat-unified/gsplat-manager.js +++ b/src/scene/gsplat-unified/gsplat-manager.js @@ -1753,7 +1753,8 @@ class GSplatManager { flipY: !!cameraNode.camera.renderTarget?.flipY, pickMode, fisheyeProj, - antiAlias: gsplat.antiAlias + antiAlias: gsplat.antiAlias, + material: gsplat.material }); projector.writeIndirectArgs( diff --git a/src/scene/gsplat-unified/gsplat-projector.js b/src/scene/gsplat-unified/gsplat-projector.js index b50f1dfa606..4e65dc74720 100644 --- a/src/scene/gsplat-unified/gsplat-projector.js +++ b/src/scene/gsplat-unified/gsplat-projector.js @@ -37,6 +37,8 @@ import { computeGsplatProjectCommonSource } from '../shader-lib/wgsl/chunks/gspl import { computeGsplatCommonSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-common.js'; import { computeGsplatTileIntersectSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-tile-intersect.js'; import computeSplatSource from '../shader-lib/wgsl/chunks/gsplat/vert/gsplatComputeSplat.js'; +import gsplatModifyDefaultSource from '../shader-lib/wgsl/chunks/gsplat/vert/gsplatModify.js'; +import gsplatHelpersSource from '../shader-lib/wgsl/chunks/gsplat/vert/gsplatHelpers.js'; /** * @import { GraphNode } from '../graph-node.js' @@ -47,6 +49,13 @@ import computeSplatSource from '../shader-lib/wgsl/chunks/gsplat/vert/gsplatComp const INDEX_COUNT = 6 * GSplatResourceBase.instanceSize; const PROJECTOR_WORKGROUP_SIZE = 256; +// Defines owned by the projector itself - user render-stage material defines that collide with +// these are ignored when merged into the projector compute, so user customization can't clobber +// the projector's own variant/format configuration. +const PROJECTOR_INTERNAL_DEFINES = new Set([ + '{CACHE_STRIDE}', 'RADIAL_SORT', 'PICK_MODE', 'GSPLAT_FISHEYE', 'GSPLAT_AA', 'GSPLAT_COLOR_FLOAT' +]); + const _cameraDir = new Vec3(); const _dispatchSize = new Vec2(); const _viewProjMat = new Mat4(); @@ -148,6 +157,28 @@ class GSplatProjector { */ _formatVersion = -1; + /** + * Change-detection key for the render-stage customization (user `gsplatModifyVS` chunk + + * material defines). When it changes, the compiled projector variants are rebuilt. + * + * @type {string} + */ + _materialKey = ''; + + /** + * The user's WGSL `gsplatModifyVS` chunk source, or null to use the default no-op chunk. + * + * @type {string|null} + */ + _userModifySource = null; + + /** + * Reference to the user material's defines map (read at compute-build time), or null. + * + * @type {Map|null} + */ + _userDefines = null; + /** @type {number} */ _allocatedCacheCount = 0; @@ -351,6 +382,13 @@ class GSplatProjector { cincludes.set('gsplatComputeSplatCS', computeSplatSource); cincludes.set('gsplatFormatDeclCS', wbFormat.getComputeInputDeclarations(fixedBindings.length)); cincludes.set('gsplatFormatReadCS', wbFormat.getReadCode()); + // splat helper functions (gsplatGetSizeFromScale / gsplatMakeSpherical) that user modify + // chunks may call - matches the helpers available in the quad renderer's vertex path. + cincludes.set('gsplatHelpersVS', gsplatHelpersSource); + // render-stage modify hooks: the user's override (from app.scene.gsplat.material) or the + // default no-op chunk. Any uniforms/textures it declares are reflected into a separate + // bind group automatically (see compute WGSL reflection). + cincludes.set('gsplatModifyVS', this._userModifySource ?? gsplatModifyDefaultSource); cincludes.set('gsplatProjectCommonCS', computeGsplatProjectCommonSource); const cdefines = new Map(); @@ -374,6 +412,16 @@ class GSplatProjector { cdefines.set('GSPLAT_COLOR_FLOAT', ''); } + // merge user render-stage material defines (skipping the projector's own), so the modify + // chunk can branch on them - mirrors GSplatQuadRenderer.copyMaterialSettings. + if (this._userDefines) { + this._userDefines.forEach((value, key) => { + if (!PROJECTOR_INTERNAL_DEFINES.has(key)) { + cdefines.set(key, value); + } + }); + } + const name = `GSplatProjector${radialSort ? 'Radial' : 'Linear'}${pickMode ? 'Pick' : ''}${fisheyeMode ? 'Fisheye' : ''}${antiAlias ? 'Aa' : ''}`; const ubFormat = fisheyeMode ? @@ -390,6 +438,31 @@ class GSplatProjector { computeUniformBufferFormats: { uniforms: ubFormat } }); + // Debug-only: warn if a user modify-chunk resource/uniform (reflected into the projector's + // auto-generated bind group) collides with a projector-internal binding name - its value is + // shadowed by the projector and won't reach the shader, so the author should rename it. + // Engine render parameters (alphaClip etc.) are not reflected, so they don't trigger this. + Debug.call(() => { + const reserved = new Set(); + const bgf = this._projectorBindGroupFormat; + bgf?.textureFormats.forEach(f => reserved.add(f.name)); + bgf?.storageBufferFormats.forEach(f => reserved.add(f.name)); + bgf?.storageTextureFormats.forEach(f => reserved.add(f.name)); + bgf?.uniformBufferFormats.forEach(f => reserved.add(f.name)); + this._projectorUniformBufferFormatFisheye?.uniforms.forEach(u => reserved.add(u.name)); + + const impl = shader.impl; + const checkName = (n) => { + if (reserved.has(n)) { + Debug.errorOnce(`GSplatProjector: render-stage modify resource '${n}' collides with a projector-internal binding and is overridden by the projector. Rename it in your gsplatModifyVS chunk.`); + } + }; + impl.computeReflectedBindGroupFormat?.textureFormats.forEach(f => checkName(f.name)); + impl.computeReflectedBindGroupFormat?.storageBufferFormats.forEach(f => checkName(f.name)); + impl.computeReflectedBindGroupFormat?.storageTextureFormats.forEach(f => checkName(f.name)); + impl.computeReflectedUniformBufferFormat?.uniforms.forEach(u => checkName(u.name)); + }); + return new Compute(device, shader, name); } @@ -422,6 +495,37 @@ class GSplatProjector { return compute; } + /** + * Syncs the render-stage customization from `app.scene.gsplat.material`: the user-overridable + * `gsplatModifyVS` chunk and the material defines are injected into the projector compute. A + * change is detected via the material's shader-chunks key plus its defines, and rebuilds the + * compiled variants. Call before {@link GSplatProjector#_getProjectorCompute}. + * + * @param {import('../materials/shader-material.js').ShaderMaterial|undefined} material - The + * gsplat render material (carries the user modify chunk, defines and parameters). + * @private + */ + _updateMaterial(material) { + + // Both keys are cached on the material and only recompute when its chunks / defines actually + // change, so this stays cheap to call every frame. (definesKey covers all defines; the + // projector's own defines are still filtered out when merged in _createProjectorCompute.) + const chunksKey = material?.shaderChunks?.key ?? ''; + const definesKey = material?.definesKey ?? ''; + const materialKey = `${chunksKey}|${definesKey}`; + + if (materialKey !== this._materialKey) { + this._materialKey = materialKey; + this._userDefines = material?.defines ?? null; + const wgslChunks = material?.getShaderChunks?.(SHADERLANGUAGE_WGSL); + this._userModifySource = wgslChunks?.get('gsplatModifyVS') ?? null; + + // customization changed - drop compiled variants so they rebuild with the new + // chunk source and defines on the next _getProjectorCompute call + this._destroyProjectorComputes(); + } + } + /** * Ensures projCache and sortKeys storage buffers are sized to at least `capacity` splats. * Both buffers are sized to the work-buffer capacity (passed in by the caller) rather than @@ -481,7 +585,8 @@ class GSplatProjector { viewportWidth, viewportHeight, flipY, pickMode = false, fisheyeProj, - antiAlias = false + antiAlias = false, + material } = params; const fisheyeMode = !!fisheyeProj?.enabled; @@ -490,6 +595,10 @@ class GSplatProjector { // doubling the projector variant count. const aaMode = antiAlias && !pickMode; + // sync render-stage customization (modify chunk + defines) before fetching the compute, + // so a change rebuilds the variant with the new source/defines + this._updateMaterial(material); + this._ensureCapacity(totalCapacity); // Reset the global render counter on the GPU. This is encoded into the same @@ -499,6 +608,19 @@ class GSplatProjector { const compute = this._getProjectorCompute(workBuffer, radialSort, pickMode, fisheyeMode, aaMode); + // Forward the user render-stage material parameters (uniforms/textures referenced by the + // modify chunk, reflected into the projector's auto-generated bind group, matched by name). + // Done BEFORE the projector sets its own parameters below, so the projector's bindings + // always win on a name collision and can never be clobbered by a user parameter. + if (material) { + const srcParams = material.parameters; + for (const name in srcParams) { + if (srcParams.hasOwnProperty(name)) { + compute.setParameter(name, srcParams[name].data); + } + } + } + // Camera position / forward (camera Z basis, matching cpu-sort path). const cameraPos = cameraNode.getPosition(); const cameraMat = cameraNode.getWorldTransform(); diff --git a/src/scene/materials/material.js b/src/scene/materials/material.js index 999eaf73f1d..9fe51298a3f 100644 --- a/src/scene/materials/material.js +++ b/src/scene/materials/material.js @@ -109,7 +109,9 @@ class Material { variants = new Map(); /** - * The set of defines used to generate the shader variants. + * The set of defines used to generate the shader variants. Mutate this only via + * {@link Material#setDefine} (or {@link Material#copy}); direct mutation bypasses the cached + * {@link Material#definesKey}. * * @type {Map} * @ignore @@ -118,6 +120,15 @@ class Material { _definesDirty = false; + /** + * Cached content key for {@link Material#defines}, or null when it needs recomputing. An empty + * defines set caches as '', so null unambiguously means "dirty". + * + * @type {string|null} + * @private + */ + _definesKey = null; + parameters = {}; /** @@ -661,6 +672,7 @@ class Material { // defines this.defines.clear(); source.defines.forEach((value, key) => this.defines.set(key, value)); + this._definesKey = null; // shader chunks this._shaderChunks = source.hasShaderChunks ? new ShaderChunks() : null; @@ -867,6 +879,26 @@ class Material { } this._definesDirty ||= modified; + if (modified) { + this._definesKey = null; + } + } + + /** + * A cached content key for the material defines, rebuilt lazily only when the defines change. + * Useful to cheaply detect define changes without scanning the map every frame. + * + * @type {string} + * @ignore + */ + get definesKey() { + if (this._definesKey === null) { + this._definesKey = Array.from(this.defines) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${k}=${v}`) + .join(','); + } + return this._definesKey; } /** diff --git a/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-tile-count.js b/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-tile-count.js index e5b620453e1..54316a5dd73 100644 --- a/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-tile-count.js +++ b/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-tile-count.js @@ -77,6 +77,7 @@ struct Uniforms { #include "gsplatComputeSplatCS" #include "gsplatFormatDeclCS" #include "gsplatFormatReadCS" +#include "gsplatModifyVS" #include "gsplatProjectCommonCS" // NOTE on tile entry cap: if a tile exceeds MAX_TILE_ENTRIES (65535), the atomicAdd diff --git a/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-project-common.js b/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-project-common.js index d7338b8e1ab..d5ffb7bac1a 100644 --- a/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-project-common.js +++ b/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-project-common.js @@ -54,17 +54,26 @@ fn projectSplatCommon( let splatId = compactedSplatIds[threadIdx]; setSplat(splatId); - let center = getCenter(); + // read the world-space center and apply the render-stage center modifier before projection + // (matches the quad renderer's gsplatVS, which calls modifySplatCenter on the model center) + let originalCenter = getCenter(); + var center = originalCenter; + modifySplatCenter(¢er); + let opacity = getOpacity(); if (opacity <= alphaClip) { return invalidProjectedSplatCommon(); } - let rotation = half4(getRotation()); - let scale = half3(getScale()); + // apply the render-stage rotation/scale modifier (e.g. scale-to-zero for reveal/hide effects). + // getRotation() is (w,x,y,z); the modify hook contract is (x,y,z,w) and computeSplatCov expects + // (w,x,y,z) - this mirrors gsplatCorner.js in the quad renderer. + var rotation: vec4f = getRotation().yzwx; + var scale: vec3f = getScale(); + modifySplatRotationScale(originalCenter, center, &rotation, &scale); let proj = computeSplatCov( - center, rotation, scale, + center, half4(rotation.wxyz), half3(scale), viewMatrix, viewProj, focal, viewportWidth, viewportHeight, nearClip, farClip, opacity, minPixelSize, diff --git a/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-projector.js b/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-projector.js index fce2594fadf..348f5be3027 100644 --- a/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-projector.js +++ b/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-projector.js @@ -69,6 +69,8 @@ struct ProjectorUniforms { #include "gsplatComputeSplatCS" #include "gsplatFormatDeclCS" #include "gsplatFormatReadCS" +#include "gsplatHelpersVS" +#include "gsplatModifyVS" #include "gsplatProjectCommonCS" // One global atomicAdd per workgroup (256 threads) — drastically lowers contention @@ -184,19 +186,25 @@ fn main( let binFrac = binFloat - f32(bin); sortKey = u32(binWeights[bin].base + binWeights[bin].divider * binFrac); + // assemble (rgb, a) and run the render-stage color modifier on the modified center, + // matching the quad renderer's gsplatVS (modifySplatColor after AA compensation). #ifdef PICK_MODE + // picking does not apply AA opacity compensation (it only gates on a binary + // opacity threshold), but the modifier may still adjust alpha. + var clr = vec4f(getColor(), opacity); + modifySplatColor(center, &clr); pcId = loadPcId().r; - alpha = opacity; + alpha = clr.a; #else - let color = getColor(); - rgb = max(color, vec3f(0.0)); + var clr = vec4f(getColor(), opacity); #if GSPLAT_AA // Bake the AA opacity compensation into the cached alpha (the hybrid VS // reads it pre-modulated). Only compiled for the non-pick variant. - alpha = opacity * proj.aaFactor; - #else - alpha = opacity; + clr.a = clr.a * proj.aaFactor; #endif + modifySplatColor(center, &clr); + rgb = max(clr.rgb, vec3f(0.0)); + alpha = clr.a; #endif valid = true;