Skip to content
Merged
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 src/scene/gsplat-unified/gsplat-compute-local-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion src/scene/gsplat-unified/gsplat-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -1753,7 +1753,8 @@ class GSplatManager {
flipY: !!cameraNode.camera.renderTarget?.flipY,
pickMode,
fisheyeProj,
antiAlias: gsplat.antiAlias
antiAlias: gsplat.antiAlias,
material: gsplat.material
});

projector.writeIndirectArgs(
Expand Down
124 changes: 123 additions & 1 deletion src/scene/gsplat-unified/gsplat-projector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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();
Expand Down Expand Up @@ -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<string, string>|null}
*/
_userDefines = null;

/** @type {number} */
_allocatedCacheCount = 0;

Expand Down Expand Up @@ -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();
Expand All @@ -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 ?
Expand All @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -481,7 +585,8 @@ class GSplatProjector {
viewportWidth, viewportHeight, flipY,
pickMode = false,
fisheyeProj,
antiAlias = false
antiAlias = false,
material
} = params;

const fisheyeMode = !!fisheyeProj?.enabled;
Expand All @@ -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
Expand All @@ -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();
Expand Down
34 changes: 33 additions & 1 deletion src/scene/materials/material.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>}
* @ignore
Expand All @@ -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 = {};

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(&center);

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down