Skip to content

Commit a343bd9

Browse files
stevekwon211claude
andcommitted
spike(wave-c+): confirm onBeforeCompile fires on SparkRenderer.material
Wave C+.1 recon outcome: Three.js's standard Material.onBeforeCompile hook fires on SparkRenderer.material (THREE.ShaderMaterial), giving us the full vertex (7551 chars) + fragment (1492 chars) shader source to patch in-place. No Spark fork required. Two key findings: 1. SplatMesh is a data generator (extends SplatGenerator extends Object3D), NOT a render mesh. The actual draw call's ShaderMaterial is owned by SparkRenderer (extends THREE.Mesh, added to scene via scene.add(spark)). Confirmed by reading dist/types/SparkRenderer.d.ts: `readonly material: THREE.ShaderMaterial`. The first spike traversed mesh and found nothing for exactly this reason. 2. The fragment shader already exports `in vec3 vNdc;` — interpolated NDC coordinates per fragment. Combined with inverse(projection * view) we can reconstruct world position per fragment without adding any varying to the vertex shader. Injection plan (deferred to C+.2): - Insert SDF uniforms before `out vec4 fragColor;`. - Inject `if (uCarveCount > 0) { worldPos = uClipToLocal * vec4(vNdc,1); for each box check abs(worldPos - center) < halfExtent; discard; }` immediately before `vec4 rgba = vRgba;` in main(). - Pass uClipToLocal = inverse(projectionMatrix * viewMatrix * meshMatrixWorld), recomputed per frame. Bonus discovery: SparkRendererOptions exposes `vertexShader?: string`, `fragmentShader?: string`, `extraUniforms?: Record<string, unknown>` — official shader-replacement API. We don't need it now but it's a clean Plan C if onBeforeCompile ever stops working. Artifacts: - src/main.ts gains a `?spike=1`-gated diagnostic that dumps the full GLSL Three.js hands the WebGL compiler. Useful for future debugging. - docs/research/2026-05-19-spark-shader-hook-spike.md — verbatim GLSL extracts, uniform list, and the exact string-replace anchors to use in C+.2. 72/72 tests still green. Typecheck clean. Dev server alive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c9168e8 commit a343bd9

2 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# Wave C+.1 — Spark shader-hook recon spike outcome
2+
3+
> **Date:** 2026-05-19.
4+
> **Status:**`Material.onBeforeCompile` fires on `SparkRenderer.material`. Wave C+.2 is unblocked. No Spark fork required.
5+
6+
## Question
7+
8+
Can we inject per-fragment SDF evaluation into Spark's splat shader without forking Spark?
9+
10+
## Answer
11+
12+
**Yes.** Three.js's standard `Material.onBeforeCompile` hook fires on Spark's internal `THREE.ShaderMaterial` and lets us modify `shader.vertexShader`, `shader.fragmentShader`, and `shader.uniforms` before compile.
13+
14+
## How we found the hook
15+
16+
The first attempt traversed `SplatMesh` and found no material — because `SplatMesh extends SplatGenerator extends THREE.Object3D`, not `THREE.Mesh`. SplatMesh is a *data generator*, not the render mesh.
17+
18+
The actual render mesh is **`SparkRenderer`**, confirmed by reading `node_modules/@sparkjsdev/spark/dist/types/SparkRenderer.d.ts`:
19+
20+
```ts
21+
export declare class SparkRenderer extends THREE.Mesh {
22+
readonly renderer: THREE.WebGLRenderer;
23+
readonly material: THREE.ShaderMaterial; // ← the hook target
24+
...
25+
}
26+
```
27+
28+
`SparkRenderer` is the THREE.Mesh added to the scene via `scene.add(spark)`. It owns the single ShaderMaterial used to rasterize every splat from every SplatMesh in the scene. **One material, one fragment shader — patch it once, every splat gets the per-fragment SDF mask.**
29+
30+
Bonus discovery: `SparkRendererOptions` already exposes `vertexShader?: string`, `fragmentShader?: string`, and `extraUniforms?: Record<string, unknown>` for officially-supported shader replacement. If `onBeforeCompile` ever stops working, we have this as Plan B without a fork.
31+
32+
## Verified at runtime
33+
34+
With `?spike=1` in the URL, the recon spike printed:
35+
36+
- `[spike] candidate: SparkRenderer.material type=ShaderMaterial`
37+
- `[spike] onBeforeCompile #1 (SparkRenderer.material)` fired during the first render
38+
- `[spike] post-tick: 1 compiles fired`
39+
40+
## Captured artifacts
41+
42+
### Uniforms (29 total)
43+
44+
```
45+
renderSize, near, far, renderToViewQuat, renderToViewPos, renderToViewBasis,
46+
renderToViewOffset, maxStdDev, minPixelRadius, maxPixelRadius, minAlpha,
47+
enable2DGS, lodInflate, preBlurAmount, blurAmount, focalDistance, apertureAngle,
48+
falloff, clipXY, focalAdjustment, encodeLinear, ordering, enableExtSplats,
49+
enableCovSplats, extSplats, extSplats2, time, deltaTime, debugFlag
50+
```
51+
52+
Notably **absent** from this list but auto-injected by Three.js because the shaders reference them: `projectionMatrix`, `modelViewMatrix`, `viewMatrix`, `modelMatrix`, `cameraPosition`. (`ShaderMaterial` does this; `RawShaderMaterial` does not.)
53+
54+
### Fragment shader (1492 chars, full source)
55+
56+
```glsl
57+
precision highp float;
58+
precision highp int;
59+
60+
#include <splatDefines>
61+
62+
uniform float near;
63+
uniform float far;
64+
uniform bool encodeLinear;
65+
uniform float time;
66+
uniform bool debugFlag;
67+
uniform float maxStdDev;
68+
uniform float minAlpha;
69+
uniform bool disableFalloff;
70+
uniform float falloff;
71+
72+
out vec4 fragColor;
73+
74+
in vec4 vRgba;
75+
in vec2 vSplatUv;
76+
in vec3 vNdc;
77+
flat in uint vSplatIndex;
78+
flat in float adjustedStdDev;
79+
80+
#include <logdepthbuf_pars_fragment>
81+
82+
void main() {
83+
vec4 rgba = vRgba;
84+
85+
float z2 = dot(vSplatUv, vSplatUv);
86+
if (z2 > (adjustedStdDev * adjustedStdDev)) {
87+
discard;
88+
}
89+
90+
if (false) {
91+
float a = rgba.a;
92+
float shifted = sqrt(z2) - max(0.0, a - 1.0);
93+
float exponent = -0.5 * max(1.0, a) * sqr(max(0.0, shifted));
94+
float min1a = min(1.0, a);
95+
rgba.a = mix(min1a, min1a * exp(exponent), falloff);
96+
} else {
97+
if (rgba.a <= 1.0) {
98+
rgba.a = mix(rgba.a, rgba.a * exp(-0.5 * z2), falloff);
99+
} else {
100+
float a = exp((rgba.a*rgba.a - 1.0) / 2.718281828459045);
101+
float alpha = 1.0 - pow(1.0 - exp(-0.5 * z2), a);
102+
rgba.a = mix(1.0, alpha, falloff);
103+
}
104+
}
105+
106+
if (rgba.a < minAlpha) {
107+
discard;
108+
}
109+
if (encodeLinear) {
110+
rgba.rgb = srgbToLinear(rgba.rgb);
111+
}
112+
113+
#ifdef PREMULTIPLIED_ALPHA
114+
fragColor = vec4(rgba.rgb * rgba.a, rgba.a);
115+
#else
116+
fragColor = rgba;
117+
#endif
118+
119+
#include <logdepthbuf_fragment>
120+
}
121+
```
122+
123+
**Key observations for C+.2:**
124+
125+
- `in vec3 vNdc;` — interpolated NDC coordinates per fragment. **Combined with `inverse(projectionMatrix * viewMatrix)`, this gives us per-fragment world position.** No varying additions to the vertex shader needed.
126+
- `void main() {\n vec4 rgba = vRgba;` — unique, stable anchor for our string replace. Insert SDF discard just before this line.
127+
- `precision highp float;` is the file's first declaration — we have control over uniform precision.
128+
129+
### Vertex shader — relevant bits
130+
131+
The vertex shader is 7551 chars; the relevant section that computes the splat's NDC position is:
132+
133+
```glsl
134+
vec3 ndcCenter = clipCenter.xyz / clipCenter.w;
135+
vec3 ndc = vec3(ndcCenter.xy + ndcOffset, ndcCenter.z);
136+
vNdc = ndc;
137+
gl_Position = vec4(ndc.xy * clipCenter.w, clipCenter.zw);
138+
```
139+
140+
So `vNdc.z` is the splat's NDC depth (constant per splat across its quad), and `vNdc.xy` is the per-fragment NDC x/y (varies as the quad is interpolated).
141+
142+
Reconstructing per-fragment world position:
143+
144+
```
145+
clipPos = vec4(vNdc, 1.0)
146+
worldPos = inverse(projectionMatrix * viewMatrix * meshMatrixWorld) * clipPos
147+
worldPos /= worldPos.w
148+
```
149+
150+
Since splatcarve adds the SplatMesh to the scene with identity transform, `meshMatrixWorld = I` and world = local. We can therefore pass a single combined `uClipToLocal = inverse(projectionMatrix * viewMatrix)` uniform from JS, recomputed each frame.
151+
152+
## Injection plan for C+.2
153+
154+
**Fragment shader, two `string.replace()` calls:**
155+
156+
1. **Add uniforms** — insert before `out vec4 fragColor;`:
157+
```glsl
158+
uniform int uCarveCount;
159+
uniform vec3 uCarveCenters[256];
160+
uniform float uCarveHalfExtents[256];
161+
uniform mat4 uClipToLocal;
162+
```
163+
164+
2. **Inject SDF discard loop** — replace `void main() {\n vec4 rgba = vRgba;` with:
165+
```glsl
166+
void main() {
167+
if (uCarveCount > 0) {
168+
vec4 worldH = uClipToLocal * vec4(vNdc, 1.0);
169+
vec3 localPos = worldH.xyz / worldH.w;
170+
for (int i = 0; i < uCarveCount; i++) {
171+
vec3 d = abs(localPos - uCarveCenters[i]);
172+
float h = uCarveHalfExtents[i];
173+
if (d.x < h && d.y < h && d.z < h) {
174+
discard;
175+
}
176+
}
177+
}
178+
vec4 rgba = vRgba;
179+
```
180+
181+
**Vertex shader: no changes.** `vNdc` is already exported.
182+
183+
**JS side:**
184+
185+
- Initial uniforms when `onBeforeCompile` fires:
186+
- `uCarveCount: { value: 0 }`
187+
- `uCarveCenters: { value: new Array(256).fill(null).map(() => new Vector3()) }`
188+
- `uCarveHalfExtents: { value: new Float32Array(256) }`
189+
- `uClipToLocal: { value: new Matrix4() }`
190+
- In the animation loop (or `onBeforeRender`):
191+
- `uClipToLocal.value.copy(camera.projectionMatrix).multiply(camera.matrixWorldInverse).multiply(mesh.matrixWorld).invert()` — recompute each frame.
192+
193+
## Risk addressed
194+
195+
-`onBeforeCompile` actually fires.
196+
- ✅ Three.js auto-binds `projectionMatrix`, `viewMatrix`, etc. (we'll pass our own `uClipToLocal` for clarity).
197+
- ✅ Anchor strings are stable: `void main() {\n vec4 rgba = vRgba;` is a unique substring in Spark's fragment shader.
198+
- ⚠️ Spark version updates may rewrite the shader. We will commit a CI check that hashes the relevant substring (separate task, post-C+.3).
199+
200+
## Decision: proceed to C+.2
201+
202+
The fallback path (vendor Spark source) is now unused. The breakthrough is reachable through the cleanest possible route: a single `onBeforeCompile` callback on a single ShaderMaterial.

src/main.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ async function main(): Promise<void> {
4545
viewer.scene.add(mesh);
4646
stats.setSplatCount(splatCount);
4747

48+
if (new URL(window.location.href).searchParams.has('spike')) {
49+
runShaderHookSpike(viewer.spark, mesh);
50+
}
51+
4852
const grid = VoxelGrid.fromAABB(bbox, params.voxResolution);
4953
const centerHash = VoxelHash.build(grid, forEachLocalCenter(mesh));
5054
stats.setVoxelInfo(centerHash.stats, params.voxResolution, grid.voxelSize);
@@ -243,6 +247,78 @@ function makeSplatMarker(voxelSize: number): Mesh {
243247
return new Mesh(geometry, material);
244248
}
245249

250+
/**
251+
* Wave C+.1 recon spike. Hooks `onBeforeCompile` on `SparkRenderer.material`
252+
* (the `THREE.ShaderMaterial` confirmed by `SparkRenderer.d.ts`) and dumps the
253+
* actual GLSL Three.js hands to the WebGL compiler.
254+
*
255+
* SparkRenderer extends THREE.Mesh and owns the splat draw call's
256+
* ShaderMaterial. The SplatMesh is only a data generator, not the rendering
257+
* mesh — confirmed by reading dist/types/SparkRenderer.d.ts. We hook the
258+
* Spark material directly.
259+
*
260+
* Also dumps any material reachable via `mesh.traverse` so we can spot
261+
* mistakes if the material moves between Spark versions.
262+
*
263+
* Gated behind `?spike=1`.
264+
*/
265+
function runShaderHookSpike(
266+
spark: import('@sparkjsdev/spark').SparkRenderer,
267+
mesh: import('@sparkjsdev/spark').SplatMesh,
268+
): void {
269+
console.group('[spike] shader hook recon');
270+
let hookCount = 0;
271+
272+
const hookOne = (mat: unknown, label: string): void => {
273+
if (!mat || typeof mat !== 'object') return;
274+
const matAny = mat as {
275+
type?: string;
276+
onBeforeCompile?: (shader: ShaderHookPayload) => void;
277+
needsUpdate?: boolean;
278+
};
279+
console.info(
280+
`[spike] candidate: ${label} type=${matAny.type ?? '<unknown>'}`,
281+
);
282+
matAny.onBeforeCompile = (shader): void => {
283+
hookCount++;
284+
console.group(`[spike] onBeforeCompile #${hookCount} (${label})`);
285+
console.info('uniforms keys:', Object.keys(shader.uniforms));
286+
console.info(`vertex length=${shader.vertexShader.length} chars`);
287+
console.info(`fragment length=${shader.fragmentShader.length} chars`);
288+
console.info('--- VERTEX SHADER (full) ---');
289+
console.log(shader.vertexShader);
290+
console.info('--- FRAGMENT SHADER (full) ---');
291+
console.log(shader.fragmentShader);
292+
console.groupEnd();
293+
};
294+
matAny.needsUpdate = true;
295+
};
296+
297+
hookOne((spark as unknown as { material: unknown }).material, 'SparkRenderer.material');
298+
mesh.traverse((obj) => {
299+
const anyObj = obj as unknown as { material?: unknown };
300+
if (!anyObj.material) return;
301+
hookOne(anyObj.material, `SplatMesh tree / ${obj.constructor.name}`);
302+
});
303+
304+
// We can't observe compile fires synchronously — Three.js triggers
305+
// onBeforeCompile during the next render pass.
306+
setTimeout(() => {
307+
console.info(
308+
`[spike] post-tick: ${hookCount === 0 ? 'STILL NO COMPILES — hook may be bypassed' : `${hookCount} compiles fired`}`,
309+
);
310+
}, 1500);
311+
312+
console.info('[spike] hooks installed; first render pass should trigger them');
313+
console.groupEnd();
314+
}
315+
316+
interface ShaderHookPayload {
317+
uniforms: Record<string, unknown>;
318+
vertexShader: string;
319+
fragmentShader: string;
320+
}
321+
246322
function requireElement<T extends HTMLElement>(selector: string): T {
247323
const el = document.querySelector<T>(selector);
248324
if (!el) throw new Error(`splatcarve: missing required DOM element "${selector}"`);

0 commit comments

Comments
 (0)