From 88b3b95171f55cd5cff4ccfd773e9ea0c651ea09 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:37:11 +0300 Subject: [PATCH 1/8] =?UTF-8?q?65=20M2=20phase=201:=20the=20BACKGROUND=20s?= =?UTF-8?q?hader=20domain=20=E2=80=94=20catalog=20stage,=20terminal=20node?= =?UTF-8?q?,=20pattern=20nodes,=20compiler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background output + Pointer / Scroll / Aspect-correct UV (background stage only), and fBm, Domain warp, Mesh gradient, Metaballs, Smooth min, Film grain, Rotate for every stage that has a pixel. compileBackgroundGraphToIR emits a whole fragment shader for the layer material; preludeDeps hoists a shared helper once; TP_OCTAVE_CAP is the budget knob. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/shaderCatalog.js | 337 ++++++++++++++++++++++++++++- src/lib/shaderCompile.js | 85 +++++++- tests/e2e/shader-compile.test.cjs | 2 +- tests/unit/backgroundGraph.test.js | 174 +++++++++++++++ 4 files changed, 587 insertions(+), 11 deletions(-) create mode 100644 tests/unit/backgroundGraph.test.js diff --git a/src/lib/shaderCatalog.js b/src/lib/shaderCatalog.js index f22ac9303..292287516 100644 --- a/src/lib/shaderCatalog.js +++ b/src/lib/shaderCatalog.js @@ -17,7 +17,7 @@ // inputs sockets [{name, type, default}] — `default` is the GLSL used when unwired // outputs sockets [{name, type, suffix}] — `suffix` swizzles the node's temp // stages which shader STAGES the node works in (absent = every stage: 'fragment', -// 'vertex' and, since P4, 'post'). uv/normal mean different things per stage +// 'vertex', since P4 'post' and since 65 M2 'background'). uv/normal mean different things per stage // and `emit` receives the stage; a node needing the view vector or dFdx is // 'fragment' only, and a node reading a SCREEN buffer is 'post' only // nativeType the GLSL type `emit` actually returns, when that is not the FIRST output's @@ -29,6 +29,8 @@ // when USE_UV is set, so a graph using UV on an untextured material would // otherwise fail to compile. // prelude GLSL function definitions hoisted once per graph (deduped by key) +// preludeDeps other defs whose prelude this one CALLS (65 M2: fBm uses Noise's hash), hoisted +// first under THEIR key — so a graph holding both nodes declares the function once // emit ({ in, params, node }) => GLSL expression string /** @typedef {'float'|'vec2'|'vec3'|'vec4'|'sampler2D'} GlslType */ @@ -42,12 +44,13 @@ * @property {any[]} [inputs] * @property {any[]} [outputs] * @property {GlslType} [nativeType] - * @property {('fragment'|'vertex'|'post')[]} [stages] which shader stages the node works + * @property {('fragment'|'vertex'|'post'|'background')[]} [stages] which shader stages the node works * in. Absent = all three. A node needing the view vector or screen-space derivatives is * fragment-only, one reading the scene's colour or depth buffer is post-only, and the * compiler refuses either elsewhere with an explanation naming both stages. * @property {string[]} [requires] * @property {string} [prelude] + * @property {string[]} [preludeDeps] * @property {string} [doc] the manual line, merged in from DOCS below * @property {(arg: any) => string} [emit] */ @@ -63,9 +66,22 @@ export const SURFACE_NODE = 'surface'; */ export const POST_OUTPUT_NODE = 'postOutput'; +/** + * 65 M2 — the single output node every BACKGROUND graph must have. A background is a + * fragment function over the LAYER's own coordinates (the screen, a picked plane's UVs, or + * the inside of the VR sphere), with the viewer's pointer and scroll as inputs — it has no + * material to patch and no frame to read, so it is a third domain with its own terminal. + */ +export const BACKGROUND_OUTPUT_NODE = 'backgroundOutput'; + /** @param {string} domain @returns {string} */ export function outputNodeFor(domain) { - return domain === 'post' ? POST_OUTPUT_NODE : SURFACE_NODE; + return domain === 'post' ? POST_OUTPUT_NODE : domain === 'background' ? BACKGROUND_OUTPUT_NODE : SURFACE_NODE; +} + +/** The shader STAGE a domain's graph compiles in (the palette filters on it). @param {string} domain */ +export function stageForDomain(domain) { + return domain === 'post' ? 'post' : domain === 'background' ? 'background' : 'fragment'; } /** float in / float out helper for the one-argument maths nodes. */ @@ -666,7 +682,8 @@ const DEFS = [ key: 'resolution', label: 'Resolution', group: 'Post', - stages: ['post'], + // 65 M2: the background material declares the same two uniforms + stages: ['post', 'background'], nativeType: 'vec4', outputs: [ { name: 'size', type: 'vec2', suffix: '.xy' }, @@ -678,7 +695,7 @@ const DEFS = [ key: 'bayer', label: 'Bayer pattern', group: 'Post', - stages: ['post'], + stages: ['post', 'background'], inputs: [{ name: 'uv', type: 'vec2', default: 'uv' }], params: [{ name: 'scale', type: 'float', default: 1, uniform: true }], outputs: [{ name: 'out', type: 'float' }], @@ -695,7 +712,12 @@ const DEFS = [ ' else if (i == 12) v = 15.0; else if (i == 13) v = 7.0; else if (i == 14) v = 13.0; else v = 5.0;\n' + ' return (v + 0.5) / 16.0;\n' + '}\n', - emit: (a) => 'tpBayer4(' + a.in.uv + ' * resolution / max(' + a.params.scale + ', 1.0))' + // a background on a PLANE has the plane's UVs, so the pixel grid comes from the + // fragment's own window position there (the post stage keeps its uv * resolution) + emit: (a) => + a.stage === 'background' + ? 'tpBayer4(gl_FragCoord.xy / max(' + a.params.scale + ', 1.0))' + : 'tpBayer4(' + a.in.uv + ' * resolution / max(' + a.params.scale + ', 1.0))' }, { key: 'edgeDetect', @@ -775,6 +797,293 @@ const DEFS = [ outputs: [] }, + // ---- patterns (65 M2: the fluid-background vocabulary — every stage that has a pixel) ---- + // Pure maths on a UV, so they work on a surface, in a post pass and in a background + // alike. Everything animated reads the SHARED clock, so a background drifts in step on + // every peer with no messages (determinism IS the netcode); only the pointer and scroll + // inputs are the viewer's own. + { + key: 'fbm', + label: 'Fractal noise (fBm)', + group: 'Pattern', + inputs: [{ name: 'uv', type: 'vec2', default: 'vUv' }], + params: [ + { name: 'scale', type: 'float', default: 3, uniform: true }, + { name: 'octaves', type: 'float', default: 5, uniform: true }, + { name: 'lacunarity', type: 'float', default: 2, uniform: true }, + { name: 'gain', type: 'float', default: 0.5, uniform: true } + ], + outputs: [{ name: 'out', type: 'float' }], + requires: ['uv'], + preludeDeps: ['noise'], + // gradient noise (smoother than Noise's value noise, no grid look) summed over + // octaves. TP_OCTAVE_CAP is the DEVICE-BUDGET knob: the background layer defines it + // lower on a headset or phone, so one graph runs everywhere and costs what the device + // can afford — a recompile on a tier change, never a different document + prelude: + '#ifndef TP_OCTAVE_CAP\n' + + '#define TP_OCTAVE_CAP 8\n' + + '#endif\n' + + 'vec2 tpGrad2(vec2 p) {\n' + + ' float h = tpHash21(p) * 6.2831853;\n' + + ' return vec2(cos(h), sin(h));\n' + + '}\n' + + 'float tpGNoise(vec2 p) {\n' + + ' vec2 i = floor(p), f = fract(p);\n' + + ' vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);\n' + + ' float a = dot(tpGrad2(i), f);\n' + + ' float b = dot(tpGrad2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));\n' + + ' float c = dot(tpGrad2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));\n' + + ' float d = dot(tpGrad2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));\n' + + ' return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n' + + '}\n' + + 'float tpFbm(vec2 p, float octaves, float lacunarity, float gain) {\n' + + ' float sum = 0.0, amp = 0.5, norm = 0.0;\n' + + ' for (int i = 0; i < 8; i++) {\n' + + ' if (float(i) >= octaves || i >= TP_OCTAVE_CAP) break;\n' + + ' sum += amp * tpGNoise(p);\n' + + ' norm += amp;\n' + + ' p = p * lacunarity + vec2(17.3, 9.1);\n' + + ' amp *= gain;\n' + + ' }\n' + + ' return clamp(0.5 + 0.7 * sum / max(norm, 0.0001), 0.0, 1.0);\n' + + '}\n', + emit: (a) => + 'tpFbm(' + a.in.uv + ' * ' + a.params.scale + ', ' + a.params.octaves + ', ' + + a.params.lacunarity + ', ' + a.params.gain + ')' + }, + { + key: 'domainWarp', + label: 'Domain warp', + group: 'Pattern', + inputs: [{ name: 'uv', type: 'vec2', default: 'vUv' }], + params: [ + { name: 'amount', type: 'float', default: 0.35, uniform: true }, + { name: 'scale', type: 'float', default: 2.5, uniform: true }, + { name: 'speed', type: 'float', default: 0.08, uniform: true } + ], + outputs: [{ name: 'out', type: 'vec2' }], + requires: ['uv', 'time'], + preludeDeps: ['noise', 'fbm'], + // UV pushed along two drifting fBm fields: the "liquid" in every fluid background + prelude: + 'vec2 tpWarp(vec2 uv, float amount, float scale, float t) {\n' + + ' vec2 p = uv * scale;\n' + + ' vec2 q = vec2(tpFbm(p + vec2(0.0, t), 4.0, 2.0, 0.5), tpFbm(p + vec2(5.2, 1.3 - t), 4.0, 2.0, 0.5));\n' + + ' return uv + amount * (q - 0.5);\n' + + '}\n', + emit: (a) => + 'tpWarp(' + a.in.uv + ', ' + a.params.amount + ', ' + a.params.scale + ', uShaderTime * ' + a.params.speed + ')' + }, + { + key: 'meshGradient', + label: 'Mesh gradient', + group: 'Pattern', + inputs: [ + { name: 'uv', type: 'vec2', default: 'vUv' }, + // unwired = no pull: the four colour points drift on the shared clock alone + { name: 'focus', type: 'vec2', default: 'vec2(-2.0)' } + ], + params: [ + { name: 'colorA', type: 'vec3', default: '#ff6a88', uniform: true }, + { name: 'colorB', type: 'vec3', default: '#ffd36e', uniform: true }, + { name: 'colorC', type: 'vec3', default: '#6a8bff', uniform: true }, + { name: 'colorD', type: 'vec3', default: '#3fe0c5', uniform: true }, + { name: 'speed', type: 'float', default: 0.35, uniform: true }, + { name: 'warp', type: 'float', default: 0.6, uniform: true }, + { name: 'pull', type: 'float', default: 0.45, uniform: true }, + { name: 'sharpness', type: 'float', default: 2.2, uniform: true } + ], + outputs: [{ name: 'out', type: 'vec3' }], + requires: ['uv', 'time'], + // four colours pinned to four drifting points, blended by inverse distance — the + // "mesh gradient" look. `focus` drags the first point (wire the Pointer in) + prelude: + 'vec3 tpMeshGrad(vec2 uv, vec2 focus, vec3 c0, vec3 c1, vec3 c2, vec3 c3, float t, float warp, float pull, float sharp) {\n' + + ' vec2 p0 = vec2(0.22, 0.26) + 0.14 * vec2(sin(t * 0.7), cos(t * 0.9));\n' + + ' vec2 p1 = vec2(0.80, 0.22) + 0.14 * vec2(cos(t * 0.6 + 1.0), sin(t * 0.8 + 2.0));\n' + + ' vec2 p2 = vec2(0.24, 0.80) + 0.14 * vec2(sin(t * 0.5 + 3.0), cos(t * 0.7 + 1.5));\n' + + ' vec2 p3 = vec2(0.78, 0.76) + 0.14 * vec2(cos(t * 0.9 + 2.5), sin(t * 0.6 + 0.5));\n' + + ' if (focus.x > -1.0) p0 = mix(p0, focus, clamp(pull, 0.0, 1.0));\n' + + ' vec2 q = uv + warp * 0.1 * vec2(sin(uv.y * 3.1 + t * 0.6), cos(uv.x * 2.7 - t * 0.5));\n' + + ' float w0 = 1.0 / pow(max(distance(q, p0), 0.002), sharp);\n' + + ' float w1 = 1.0 / pow(max(distance(q, p1), 0.002), sharp);\n' + + ' float w2 = 1.0 / pow(max(distance(q, p2), 0.002), sharp);\n' + + ' float w3 = 1.0 / pow(max(distance(q, p3), 0.002), sharp);\n' + + ' return (c0 * w0 + c1 * w1 + c2 * w2 + c3 * w3) / (w0 + w1 + w2 + w3);\n' + + '}\n', + emit: (a) => + 'tpMeshGrad(' + a.in.uv + ', ' + a.in.focus + ', ' + a.params.colorA + ', ' + a.params.colorB + ', ' + + a.params.colorC + ', ' + a.params.colorD + ', uShaderTime * ' + a.params.speed + ', ' + + a.params.warp + ', ' + a.params.pull + ', ' + a.params.sharpness + ')' + }, + { + key: 'blobs', + label: 'Metaballs', + group: 'Pattern', + inputs: [ + { name: 'uv', type: 'vec2', default: 'vUv' }, + // one extra blob that follows whatever is wired here (the Pointer); unwired = none + { name: 'attract', type: 'vec2', default: 'vec2(-10.0)' } + ], + params: [ + { name: 'count', type: 'float', default: 5, uniform: true }, + { name: 'radius', type: 'float', default: 0.12, uniform: true }, + { name: 'speed', type: 'float', default: 0.5, uniform: true }, + { name: 'smoothness', type: 'float', default: 0.12, uniform: true }, + { name: 'softness', type: 'float', default: 0.01, uniform: true }, + { name: 'attractRadius', type: 'float', default: 0.1, uniform: true } + ], + nativeType: 'vec2', + outputs: [ + { name: 'mask', type: 'float', suffix: '.y' }, + { name: 'sdf', type: 'float', suffix: '.x' } + ], + requires: ['uv', 'time'], + preludeDeps: ['noise', 'smoothMin'], + // up to eight drifting circles merged with a smooth minimum (an SDF), so they melt + // into each other like a lava lamp. Positions hash from the blob index: every peer + // draws the same blobs at the same moment + prelude: + 'vec2 tpBlobs(vec2 uv, vec2 extra, float count, float radius, float t, float k, float soft, float extraR) {\n' + + ' float d = 1000.0;\n' + + ' for (int i = 0; i < 8; i++) {\n' + + ' if (float(i) >= count) break;\n' + + ' float fi = float(i);\n' + + ' vec2 h = vec2(tpHash21(vec2(fi, 3.1)), tpHash21(vec2(7.7, fi)));\n' + + ' vec2 c = 0.5 + 0.32 * vec2(sin(t * (0.5 + h.x) + h.y * 6.2831853), cos(t * (0.4 + h.y * 0.8) + h.x * 6.2831853));\n' + + ' float r = radius * (0.7 + 0.6 * tpHash21(vec2(fi, 11.0)));\n' + + ' d = tpSmin(d, length(uv - c) - r, k);\n' + + ' }\n' + + ' if (extra.x > -5.0 && extraR > 0.0) d = tpSmin(d, length(uv - extra) - extraR, k);\n' + + ' float s = max(soft, 0.0005);\n' + + ' return vec2(d, 1.0 - smoothstep(-s, s, d));\n' + + '}\n', + emit: (a) => + 'tpBlobs(' + a.in.uv + ', ' + a.in.attract + ', ' + a.params.count + ', ' + a.params.radius + ', uShaderTime * ' + + a.params.speed + ', ' + a.params.smoothness + ', ' + a.params.softness + ', ' + a.params.attractRadius + ')' + }, + { + key: 'smoothMin', + label: 'Smooth min', + group: 'Math', + inputs: [ + { name: 'a', type: 'float', default: '0.0' }, + { name: 'b', type: 'float', default: '0.0' } + ], + params: [{ name: 'k', type: 'float', default: 0.1, uniform: true }], + outputs: [{ name: 'out', type: 'float' }], + // the polynomial smooth minimum: min(a, b) with the corner rounded by k — how two + // distance fields MELT together instead of meeting at a crease + prelude: + 'float tpSmin(float a, float b, float k) {\n' + + ' float h = clamp(0.5 + 0.5 * (b - a) / max(k, 0.0001), 0.0, 1.0);\n' + + ' return mix(b, a, h) - k * h * (1.0 - h);\n' + + '}\n', + emit: (a) => 'tpSmin(' + a.in.a + ', ' + a.in.b + ', ' + a.params.k + ')' + }, + { + key: 'grain', + label: 'Film grain', + group: 'Pattern', + // gl_FragCoord: a pixel-locked pattern, so there is no vertex-stage meaning + stages: ['fragment', 'post', 'background'], + params: [ + { name: 'amount', type: 'float', default: 0.06, uniform: true }, + { name: 'size', type: 'float', default: 1.5, uniform: true }, + { name: 'animate', type: 'float', default: 1, uniform: true } + ], + outputs: [{ name: 'out', type: 'float' }], + requires: ['time'], + preludeDeps: ['noise'], + // -amount/2 .. +amount/2 per screen pixel cell; ADD it to a colour. Re-rolled 24 + // times a second on the shared clock when animate is 1 + prelude: + 'float tpGrain(vec2 px, float size, float t) {\n' + + ' vec2 cell = floor(px / max(size, 1.0));\n' + + ' return tpHash21(cell + floor(t * 24.0) * vec2(37.1, 17.3)) - 0.5;\n' + + '}\n', + emit: (a) => + '(tpGrain(gl_FragCoord.xy, ' + a.params.size + ', uShaderTime * ' + a.params.animate + ') * ' + a.params.amount + ')' + }, + { + key: 'rotateUv', + label: 'Rotate', + group: 'UV', + inputs: [{ name: 'uv', type: 'vec2', default: 'vUv' }], + params: [ + { name: 'angle', type: 'float', default: 0, uniform: true }, + { name: 'speed', type: 'float', default: 0, uniform: true }, + { name: 'center', type: 'vec2', default: [0.5, 0.5], uniform: true } + ], + outputs: [{ name: 'out', type: 'vec2' }], + requires: ['uv', 'time'], + // degrees, plus degrees per second on the shared clock + prelude: + 'vec2 tpRotate(vec2 uv, vec2 c, float deg) {\n' + + ' float r = radians(deg);\n' + + ' float s = sin(r), k = cos(r);\n' + + ' vec2 p = uv - c;\n' + + ' return vec2(k * p.x - s * p.y, s * p.x + k * p.y) + c;\n' + + '}\n', + emit: (a) => + 'tpRotate(' + a.in.uv + ', ' + a.params.center + ', ' + a.params.angle + ' + uShaderTime * ' + a.params.speed + ')' + }, + + // ---- background inputs (65 M2: the layer's own uniforms — background stage only) ---- + { + key: 'pointer', + label: 'Pointer', + group: 'Background', + stages: ['background'], + // smoothed, in the layer's own 0..1 space (the screen, the plane's UVs, or where the + // right controller points on the VR sphere). LOCAL: each viewer's own pointer moves + // their own background — nothing is sent + nativeType: 'vec4', + outputs: [ + { name: 'position', type: 'vec2', suffix: '.xy' }, + { name: 'velocity', type: 'vec2', suffix: '.zw' } + ], + emit: () => 'uPointer' + }, + { + key: 'scroll', + label: 'Scroll', + group: 'Background', + stages: ['background'], + nativeType: 'vec2', + outputs: [ + { name: 'progress', type: 'float', suffix: '.x' }, + { name: 'speed', type: 'float', suffix: '.y' } + ], + emit: () => 'uScroll' + }, + { + key: 'aspectUv', + label: 'Aspect-correct UV', + group: 'Background', + stages: ['background'], + inputs: [{ name: 'uv', type: 'vec2', default: 'vUv' }], + outputs: [{ name: 'out', type: 'vec2' }], + requires: ['uv'], + // stretches x by the layer's width/height about the centre, so a circle stays round + // on a wide screen (wire the Pointer's position through one too, to compare them) + emit: (a) => '((' + a.in.uv + ' - 0.5) * vec2(uAspect, 1.0) + 0.5)' + }, + { + key: BACKGROUND_OUTPUT_NODE, + label: 'Background output', + group: 'Output', + stages: ['background'], + inputs: [ + { name: 'color', type: 'vec3', default: null }, + // unwired: opaque. Below 1 shows what is behind — the sky in screen mode, the + // plane's own material in plane mode + { name: 'alpha', type: 'float', default: '1.0' } + ], + outputs: [] + }, + // ---- the output ------------------------------------------------------------- { key: SURFACE_NODE, @@ -872,6 +1181,22 @@ const DOCS = { edgeDetect: 'A line strength, 0..1, where depth or normals change sharply — silhouettes and creases. Mix a line colour over the scene colour by it for an ink look.', ambientOcclusion: 'A cheap screen-space occlusion from depth alone, 0 open to 1 tucked into a corner. Darken the scene colour by it for contact shading you can tune in a graph.', + // patterns (65 M2) + fbm: 'Layered gradient noise, 0..1: soft clouds, smoke, marble. More octaves add finer detail; on a headset or phone the background layer caps them so the same graph stays within budget.', + domainWarp: 'Pushes UV along two slowly drifting noise fields — the liquid, flowing look of a fluid background. Feed its output into anything that takes a UV.', + meshGradient: 'Four colours pinned to four drifting points and blended smoothly: the modern mesh-gradient backdrop. Wire the Pointer into focus to pull the first colour towards it.', + blobs: 'Up to eight drifting circles that melt into each other (metaballs, a lava lamp). Mask is 0..1 inside; sdf is the signed distance for glows and outlines. Wire the Pointer into attract for a blob that follows it.', + smoothMin: 'The smaller of two distances with the corner rounded off by k, so two shapes merge softly instead of meeting at a crease.', + grain: 'Per-pixel film grain centred on zero — add it to a colour to break up banding in smooth gradients. Animate 1 re-rolls it 24 times a second.', + rotateUv: 'Turns UV about a centre by an angle in degrees, plus a spin speed in degrees per second.', + + // background inputs (65 M2) + pointer: "Where this viewer's pointer is over the background (0..1, smoothed) and how fast it is moving. In VR it is where the right controller points. Local to each viewer: nothing is sent.", + scroll: 'How far the page has scrolled (0..1) and how fast — for scroll-driven heroes and embeds. In the editor the mouse wheel over the background nudges it.', + aspectUv: 'UV stretched to the layer\'s width/height, so circles stay round on a wide screen. Put the Pointer\'s position through one too before comparing the two.', + [BACKGROUND_OUTPUT_NODE]: + 'The background graph\'s output: the colour drawn behind the scene (full screen, on a picked plane, or on a big sphere around you in VR). Alpha below 1 lets what is behind show through.', + // output [POST_OUTPUT_NODE]: "The post graph's output: the colour this effect writes for the pixel, with alpha left to the frame's own unless you wire it. Everything upstream of color is one fullscreen pass.", diff --git a/src/lib/shaderCompile.js b/src/lib/shaderCompile.js index 5dd231087..f9419924a 100644 --- a/src/lib/shaderCompile.js +++ b/src/lib/shaderCompile.js @@ -22,10 +22,10 @@ // recomputed per consumer), and loop forever on a cycle. Both are handled by the // memo + the in-progress set, the PATH-based guard the flow editor uses. -import { shaderNodeDef, outputTypeOf, SURFACE_NODE, POST_OUTPUT_NODE } from './shaderCatalog.js'; +import { shaderNodeDef, outputTypeOf, SURFACE_NODE, POST_OUTPUT_NODE, BACKGROUND_OUTPUT_NODE } from './shaderCatalog.js'; /** @typedef {'float'|'vec2'|'vec3'|'vec4'|'sampler2D'} GlslType */ -/** @typedef {'fragment'|'vertex'|'post'} ShaderStage */ +/** @typedef {'fragment'|'vertex'|'post'|'background'} ShaderStage */ /** The FRAGMENT taps the inject backend exposes, and the type each expects. */ const TAP_TYPES = { @@ -44,8 +44,11 @@ const VERTEX_TAP_TYPES = { position: 'vec3' }; /** The POST tap: one colour (plus the frame's own alpha unless wired). */ const POST_TAP_TYPES = { color: 'vec3', alpha: 'float' }; +/** 65 M2: the BACKGROUND tap — a colour and an alpha (opaque unless wired). */ +const BACKGROUND_TAP_TYPES = { color: 'vec3', alpha: 'float' }; + /** Stage names as a user would recognise them. @type {Record} */ -const STAGE_LABEL = { fragment: 'surface', vertex: 'vertex displacement', post: 'post-processing' }; +const STAGE_LABEL = { fragment: 'surface', vertex: 'vertex displacement', post: 'post-processing', background: 'background' }; /** * The one helper every depth-reading post node calls, emitted ONCE by the post pass @@ -87,8 +90,24 @@ const POST_EQUIVALENT = { 'normalize(vNormal)': null }; +/** + * 65 M2: the BACKGROUND stage. The layer's material names its coordinate varying `vUv` + * (the screen, the plane's UVs, or the sphere's) so a UV default needs no translation; + * a surface normal has no meaning on a backdrop and is refused by name. + * @type {Record} + */ +const BACKGROUND_EQUIVALENT = { + 'normalize(vNormal)': null +}; + +/** + * The layer uniforms the BACKGROUND material always declares itself (backgroundLayer.js), + * so the compiler must not declare them a second time. + */ +export const BACKGROUND_BUILTIN_UNIFORMS = ['uShaderTime', 'uPointer', 'uScroll', 'uAspect', 'resolution', 'texelSize']; + /** @type {Record>} */ -const STAGE_EQUIVALENT = { vertex: VERTEX_EQUIVALENT, post: POST_EQUIVALENT }; +const STAGE_EQUIVALENT = { vertex: VERTEX_EQUIVALENT, post: POST_EQUIVALENT, background: BACKGROUND_EQUIVALENT }; /** * Convert `expr` from `from` to `to`. GLSL will not do this silently, and a mismatch is @@ -260,6 +279,17 @@ function createCompiler(graph, outputType) { } inProgress.add(nodeId); for (const r of def.requires ?? []) requires.add(r); + // a dependency's prelude goes in FIRST and under ITS key, so a graph holding both + // the helper and its user declares the shared function once (GLSL has no overloads + // by redefinition — a duplicate is a compile error) + for (const dep of def.preludeDeps ?? []) { + const depDef = shaderNodeDef(dep); + for (const inner of depDef?.preludeDeps ?? []) { + const innerDef = shaderNodeDef(inner); + if (innerDef?.prelude && !preludes.has(inner)) preludes.set(inner, innerDef.prelude); + } + if (depDef?.prelude && !preludes.has(dep)) preludes.set(dep, depDef.prelude); + } if (def.prelude) preludes.set(def.key, def.prelude); // params: a uniform-backed one becomes a live uniform, a plain one a literal @@ -473,6 +503,53 @@ export function compilePostGraphToIR(graph) { }; } +/** + * 65 M2 — compile a BACKGROUND graph document into the layer material's fragment shader. + * + * ONE pass, stage 'background', over the Background output node's `color` (+ `alpha`). + * The result is a complete fragment shader for a THREE.ShaderMaterial whose vertex shader + * (backgroundLayer.js) writes the `vUv` varying: the graph's preludes, its own uniform + * declarations, and a `main` writing gl_FragColor through three's colour-space chunk. The + * layer uniforms (shared clock, pointer, scroll, aspect, resolution) are declared by the + * material for every graph, so they are left out of the declarations here — and + * `usesPointer` / `usesScroll` / `usesClock` tell the layer which inputs are worth feeding. + * @param {{nodes: any[], edges: any[]}} graph + * @returns {{ok: boolean, ir?: {fragment: string, uniforms: any[], usesClock: boolean, usesPointer: boolean, usesScroll: boolean, requires: string[]}, errors?: string[]}} + */ +export function compileBackgroundGraphToIR(graph) { + const { output, errors, uniforms, preludes, makePass } = createCompiler(graph, BACKGROUND_OUTPUT_NODE); + if (!output) return { ok: false, errors: ['The graph has no Background output node.'] }; + const pass = makePass('background'); + const taps = pass.walkTaps(BACKGROUND_TAP_TYPES); + if (!taps.color && !errors.length) + errors.push("Nothing is connected to the Background output's colour, so the layer would draw nothing."); + if (errors.length) return { ok: false, errors }; + const types = new Set((graph?.nodes ?? []).map((/** @type {any} */ n) => n?.type)); + const list = [...uniforms.values()].filter((u) => !BACKGROUND_BUILTIN_UNIFORMS.includes(u.name)); + const decls = list.map((u) => 'uniform ' + u.type + ' ' + u.name + ';').join('\n'); + const fragment = + 'varying vec2 vUv;\n' + + 'uniform float uShaderTime;\nuniform vec4 uPointer;\nuniform vec2 uScroll;\nuniform float uAspect;\n' + + 'uniform vec2 resolution;\nuniform vec2 texelSize;\n' + + [...preludes.values()].join('\n') + + (decls ? decls + '\n' : '') + + 'void main() {\n\t' + + pass.statements.join('\n\t') + + '\n\tgl_FragColor = vec4(' + taps.color + ', clamp(' + (taps.alpha ?? '1.0') + ', 0.0, 1.0));\n' + + '\t#include \n}'; + return { + ok: true, + ir: { + fragment, + uniforms: list, + usesClock: pass.requires.has('time'), + usesPointer: types.has('pointer'), + usesScroll: types.has('scroll'), + requires: [...pass.requires] + } + }; +} + /** node ids can contain anything; GLSL identifiers cannot. @param {string} id */ function safe(id) { return String(id).replace(/[^A-Za-z0-9_]/g, '_'); diff --git a/tests/e2e/shader-compile.test.cjs b/tests/e2e/shader-compile.test.cjs index 538c0796f..49672f607 100644 --- a/tests/e2e/shader-compile.test.cjs +++ b/tests/e2e/shader-compile.test.cjs @@ -109,7 +109,7 @@ const edge = (from, to, targetHandle, sourceHandle = 'out') => ({ const defs = shaderNodeDefs(); // the TERMINAL nodes are the exception: they emit nothing because nothing reads them — // each domain's graph ends at one (P4 added the post half's) - const terminals = ['surface', 'postOutput']; + const terminals = ['surface', 'postOutput', 'backgroundOutput']; // 65 M2 added the background's const bad = defs.filter((d) => !d.key || !d.label || !d.group || (!terminals.includes(d.key) && !d.emit)); check(bad.length === 0, defs.length + ' node defs, all with key/label/group/emit: ' + JSON.stringify(bad.map((d) => d.key))); check(!!shaderNodeDef('surface'), 'the Surface output def exists'); diff --git a/tests/unit/backgroundGraph.test.js b/tests/unit/backgroundGraph.test.js new file mode 100644 index 000000000..ca0af403c --- /dev/null +++ b/tests/unit/backgroundGraph.test.js @@ -0,0 +1,174 @@ +// 65 M2 — the BACKGROUND shader domain: catalog + compiler, no browser (both modules are +// pure). The GLSL itself is compiled for real by tests/e2e/background-glsl.test.cjs +// (glslangValidator) and rendered by tests/e2e/background-layer.test.cjs. +import { describe, it, expect } from 'vitest'; +import { + compileBackgroundGraphToIR, + compileShaderGraphToIR, + BACKGROUND_BUILTIN_UNIFORMS +} from '../../src/lib/shaderCompile.js'; +import { + shaderNodeDef, + shaderNodeDefs, + outputNodeFor, + stageForDomain, + BACKGROUND_OUTPUT_NODE +} from '../../src/lib/shaderCatalog.js'; + +let seq = 0; +const node = (type, data = {}) => ({ + id: type + '_' + ++seq, + type, + data, + position: { x: 0, y: 0 } +}); +const edge = (from, to, targetHandle, sourceHandle = 'out') => ({ + id: 'e' + ++seq, + source: from.id, + sourceHandle, + target: to.id, + targetHandle +}); +/** count whole-word occurrences of a GLSL function DEFINITION */ +const defs = (glsl, name) => + (glsl.match(new RegExp('\\b(?:float|vec2|vec3|vec4) ' + name + '\\(', 'g')) ?? []).length; + +describe('background domain plumbing', () => { + it('names its terminal and stage', () => { + expect(outputNodeFor('background')).toBe(BACKGROUND_OUTPUT_NODE); + expect(outputNodeFor('post')).toBe('postOutput'); + expect(outputNodeFor('surface')).toBe('surface'); + expect(stageForDomain('background')).toBe('background'); + expect(stageForDomain('surface')).toBe('fragment'); + }); + it('documents every new node', () => { + for (const key of [ + 'fbm', + 'domainWarp', + 'meshGradient', + 'blobs', + 'smoothMin', + 'grain', + 'rotateUv', + 'pointer', + 'scroll', + 'aspectUv', + BACKGROUND_OUTPUT_NODE + ]) + expect(shaderNodeDef(key)?.doc?.length ?? 0, key).toBeGreaterThan(12); + expect(shaderNodeDefs().filter((d) => !d.doc)).toEqual([]); + }); +}); + +describe('compileBackgroundGraphToIR', () => { + it('refuses a graph with no output, and one with nothing wired', () => { + expect(compileBackgroundGraphToIR({ nodes: [node('color')], edges: [] }).ok).toBe(false); + const r = compileBackgroundGraphToIR({ nodes: [node(BACKGROUND_OUTPUT_NODE)], edges: [] }); + expect(r.ok).toBe(false); + expect(r.errors?.[0]).toMatch(/colour/); + }); + + it('compiles the smallest graph into a whole fragment shader', () => { + const c = node('color', { value: '#ff0000' }); + const out = node(BACKGROUND_OUTPUT_NODE); + const r = compileBackgroundGraphToIR({ nodes: [c, out], edges: [edge(c, out, 'color')] }); + expect(r.ok).toBe(true); + const f = r.ir?.fragment ?? ''; + expect(f).toMatch(/varying vec2 vUv;/); + expect(f).toMatch(/void main\(\)/); + expect(f).toMatch(/gl_FragColor = vec4\(/); + expect(f).toMatch(/#include /); + // opaque unless alpha is wired + expect(f).toMatch(/clamp\(1\.0, 0\.0, 1\.0\)/); + expect(r.ir?.usesPointer).toBe(false); + }); + + it('declares the layer uniforms exactly once (the material owns them)', () => { + const p = node('pointer'); + const t = node('time'); + const b = node('blobs'); + const out = node(BACKGROUND_OUTPUT_NODE); + const r = compileBackgroundGraphToIR({ + nodes: [p, t, b, out], + edges: [edge(p, b, 'attract', 'position'), edge(b, out, 'color', 'mask')] + }); + expect(r.ok, String(r.errors)).toBe(true); + const f = r.ir?.fragment ?? ''; + for (const name of BACKGROUND_BUILTIN_UNIFORMS) + expect( + f.split('uniform ').filter((s) => s.match(new RegExp('^\\w+ ' + name + ';'))).length, + name + ).toBe(1); + expect(r.ir?.usesPointer).toBe(true); + expect(r.ir?.usesClock).toBe(true); + expect(r.ir?.uniforms.some((u) => u.name === 'uShaderTime')).toBe(false); + }); + + it('hoists a shared prelude ONCE when a graph holds the helper and its users', () => { + const n = node('noise'); + const f = node('fbm'); + const w = node('domainWarp'); + const g = node('grain'); + const add1 = node('add'); + const add2 = node('add'); + const out = node(BACKGROUND_OUTPUT_NODE); + const r = compileBackgroundGraphToIR({ + nodes: [n, f, w, g, add1, add2, out], + edges: [ + edge(w, f, 'uv'), + edge(n, add1, 'a'), + edge(f, add1, 'b'), + edge(add1, add2, 'a'), + edge(g, add2, 'b'), + edge(add2, out, 'color') + ] + }); + expect(r.ok, String(r.errors)).toBe(true); + const glsl = r.ir?.fragment ?? ''; + expect(defs(glsl, 'tpHash21')).toBe(1); + expect(defs(glsl, 'tpFbm')).toBe(1); + expect(defs(glsl, 'tpWarp')).toBe(1); + // the helper comes BEFORE its users + expect(glsl.indexOf('float tpHash21(')).toBeLessThan(glsl.indexOf('float tpFbm(')); + expect(glsl.indexOf('float tpFbm(')).toBeLessThan(glsl.indexOf('vec2 tpWarp(')); + // the device-budget knob is a guarded define, so the layer can lower it + expect(glsl).toMatch(/#ifndef TP_OCTAVE_CAP/); + }); + + it('refuses a surface-only node by name', () => { + const nrm = node('normal'); + const out = node(BACKGROUND_OUTPUT_NODE); + const r = compileBackgroundGraphToIR({ nodes: [nrm, out], edges: [edge(nrm, out, 'color')] }); + expect(r.ok).toBe(false); + expect(r.errors?.[0]).toMatch(/only works in the surface stage, not in background/); + }); + + it('keeps background inputs out of the other domains', () => { + const p = node('pointer'); + const s = node('surface'); + const r = compileShaderGraphToIR({ nodes: [p, s], edges: [edge(p, s, 'albedo', 'position')] }); + expect(r.ok).toBe(false); + expect(r.errors?.[0]).toMatch(/background stage/); + }); + + it('lets the new pattern nodes drive an ordinary SURFACE too', () => { + const f = node('fbm'); + const m = node('meshGradient'); + const mul = node('multiply'); + const s = node('surface'); + const r = compileShaderGraphToIR({ + nodes: [f, m, mul, s], + edges: [edge(m, mul, 'a'), edge(f, mul, 'b'), edge(mul, s, 'albedo')] + }); + expect(r.ok, String(r.errors)).toBe(true); + expect(defs(r.ir.prelude, 'tpHash21')).toBe(1); + }); + + it('the Bayer node reads the pixel grid in a background', () => { + const b = node('bayer'); + const out = node(BACKGROUND_OUTPUT_NODE); + const r = compileBackgroundGraphToIR({ nodes: [b, out], edges: [edge(b, out, 'color')] }); + expect(r.ok, String(r.errors)).toBe(true); + expect(r.ir?.fragment).toMatch(/tpBayer4\(gl_FragCoord\.xy/); + }); +}); From 3f790a6f47131265d9e18efeb761f739f6ed7776 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:52:01 +0300 Subject: [PATCH 2/8] =?UTF-8?q?65=20M2=20phase=202:=20the=20background=20L?= =?UTF-8?q?AYER=20=E2=80=94=20screen=20/=20plane=20/=20VR=20sphere,=20poin?= =?UTF-8?q?ter=20+=20scroll,=20budget=20tier,=20packs=20preset=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shaderGraphs['background'] (domain 'background') carries the layer settings, so wire, undo, saves and late join are inherited. Configure Scene ▸ Background + a Background tab in the shader editor. Fixes shaderGraphsSnapshot pruning post/background documents as orphans (a selection still carries none). e2e background-layer written, first run pending. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 5 + src/components/editors/ShaderEditor.svelte | 71 ++- src/components/menu/Inspector.svelte | 2 + .../inspector/BackgroundLayerSection.svelte | 127 +++++ src/lib/backgroundLayer.js | 538 ++++++++++++++++++ src/lib/backgroundLayerCore.js | 111 ++++ src/lib/backgroundPresets.js | 111 ++++ src/lib/backgroundPresetsCore.js | 143 +++++ src/lib/debugHooks.js | 3 + src/lib/postGraphs.js | 7 +- src/lib/sessions.js | 6 +- src/lib/shaderCompile.js | 4 +- src/lib/shaderGraph.js | 36 +- tests/e2e/background-layer.test.cjs | 200 +++++++ tests/e2e/background-presets.test.cjs | 87 +++ tests/unit/backgroundGraph.test.js | 1 + tests/unit/backgroundLayer.test.js | 192 +++++++ 17 files changed, 1623 insertions(+), 21 deletions(-) create mode 100644 src/components/menu/inspector/BackgroundLayerSection.svelte create mode 100644 src/lib/backgroundLayer.js create mode 100644 src/lib/backgroundLayerCore.js create mode 100644 src/lib/backgroundPresets.js create mode 100644 src/lib/backgroundPresetsCore.js create mode 100644 tests/e2e/background-layer.test.cjs create mode 100644 tests/e2e/background-presets.test.cjs create mode 100644 tests/unit/backgroundLayer.test.js diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 9065cea28..ce68f8c4d 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -72,6 +72,7 @@ import { measureMode, measureClick, measureHoverAt } from '$lib/measure'; import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; import { setParticleRoot } from '$lib/particleRuntime'; + import { setBackgroundRoot, tickBackgroundLayer } from '$lib/backgroundLayer'; // 65 M2 import WaterLayer from './water/WaterLayer.svelte'; import { registerEditResumeSources } from '$lib/editResume'; import { sendPing } from '$lib/ping'; @@ -426,6 +427,7 @@ tickAnimatedMixers(); // imported clips run on the synced clock tickPackBehaviors(); // 33 P2: doors/lids/levers pose from their shared state (rest in Edit) tickSim($objectsGroup, camera.current, renderer, performance.now()); // 36-sim (after mixers: jiggle rides the posed bones) + tickBackgroundLayer(camera.current, renderer, performance.now()); // 65 M2: the shader background (inert without one) tickAnimationPreview(); // Animation window: local transform preview (not synced) tickSequences(); // 48 S2: shots / clips / audio / events of running sequences tickMeshEdit(); // vertex handles follow the object if it moves (119) @@ -1830,6 +1832,9 @@ sceneObjects — they'd leak into GLTF sync). oncreate passes the ref DIRECTLY (the { ref } destructure trap, N1). --> setParticleRoot(ref)} /> + + setBackgroundRoot(ref)} /> diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte index f6199f18f..ccede8631 100644 --- a/src/components/editors/ShaderEditor.svelte +++ b/src/components/editors/ShaderEditor.svelte @@ -40,7 +40,11 @@ shaderRefusalReason } from '$lib/shaderGraph'; import { beginShaderGesture, endShaderGesture } from '$lib/shaderSync'; - import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE, POST_OUTPUT_NODE } from '$lib/shaderCatalog'; + import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE, POST_OUTPUT_NODE, BACKGROUND_OUTPUT_NODE, stageForDomain } from '$lib/shaderCatalog'; + // 65 M2: the BACKGROUND domain — one document per scene, so its scope is fixed + import { BACKGROUND_KEY, removeBackground } from '$lib/backgroundLayer'; + import { backgroundPresets, loadBackgroundPresets, applyBackgroundPreset } from '$lib/backgroundPresets'; + import { BUILTIN_PRESET_KEY } from '$lib/backgroundPresetsCore'; // P4: the POST domain. The editor is one surface for two domains — same cards, same // palette, same pane menu — because a post graph IS a shader graph; what differs is // which nodes mean anything, which terminal node the graph ends at, and (since a post @@ -100,7 +104,8 @@ (def) => def.key !== SURFACE_NODE && def.key !== POST_OUTPUT_NODE && - (!def.stages || def.stages.includes(which === 'post' ? 'post' : 'fragment')) + def.key !== BACKGROUND_OUTPUT_NODE && + (!def.stages || def.stages.includes(/** @type {any} */ (stageForDomain(which)))) ); const catalog = $derived(catalogFor($shaderDomain)); const groups = $derived([...new Set(catalog.map((def) => def.group))]); @@ -111,6 +116,10 @@ // ---- scope: selection-driven in SURFACE, picked in POST --------------------- const selectedUuid = $derived($selectedObjects?.length === 1 ? $selectedObjects[0] : null); const isPost = $derived($shaderDomain === 'post'); + const isBg = $derived($shaderDomain === 'background'); + $effect(() => { + if (isBg) void loadBackgroundPresets(); + }); /** every post graph, re-derived off the store so a new one appears at once */ /** @param {any} _poke */ const graphsOf = (_poke) => postGraphKeys(); @@ -121,17 +130,21 @@ ? $activePostGraph : (postGraphs[0]?.key ?? '') ); - const scope = $derived(isPost ? postScope : (selectedUuid ?? SCENE_GRAPH_KEY)); + const scope = $derived(isBg ? BACKGROUND_KEY : isPost ? postScope : (selectedUuid ?? SCENE_GRAPH_KEY)); /** the navigator's list, split by domain (see its markup below) */ const treeDocuments = $derived( Object.fromEntries( - Object.entries($shaderGraphs).filter(([key]) => key.startsWith('post:') === isPost) + Object.entries($shaderGraphs).filter(([key]) => + isBg ? key === BACKGROUND_KEY : key !== BACKGROUND_KEY && key.startsWith('post:') === isPost + ) ) ); const doc = $derived(scope ? ($shaderGraphs[scope] ?? null) : null); const errors = $derived(scope ? ($shaderErrors[scope] ?? []) : []); const ownerName = $derived( - isPost + isBg + ? 'The background' + : isPost ? scope ? postGraphName(scope) : 'No post effect' @@ -140,7 +153,9 @@ : $objectsGroup?.getObjectByProperty('uuid', scope)?.name || 'This object' ); const scopeLabel = $derived( - isPost + isBg + ? 'Scene background' + : isPost ? ownerName + ' — post effect' : scope === SCENE_GRAPH_KEY ? 'Scene default material' @@ -280,6 +295,10 @@ // ---- actions --------------------------------------------------------------- function createGraph() { + if (isBg) { + void applyBackgroundPreset(BUILTIN_PRESET_KEY); + return; + } if (isPost) { // in the post half "create" MINTS a document (there is no object to attach one // to), and the new one becomes the scope so you are looking at what you made @@ -311,6 +330,10 @@ } function removeGraph() { + if (isBg) { + removeBackground(); + return; + } if (isPost) { if (scope) deletePostGraph(scope); activePostGraph.set(null); @@ -425,7 +448,7 @@ { label: 'Disconnect all', action: () => disconnectNode(id) }, // the Surface output is the graph's terminal — there is exactly one, and a // graph without it cannot compile at all - ...(node.type === SURFACE_NODE + ...(node.type === SURFACE_NODE || node.type === BACKGROUND_OUTPUT_NODE ? [] : [{ label: 'Delete node', danger: true, action: () => deleteNode(id) }]) ] @@ -653,7 +676,7 @@ @@ -664,6 +687,13 @@ title="Post effects: a fragment over the finished frame, added to the scene look" onclick={() => shaderDomain.set('post')}>Post + {#if isPost && postGraphs.length} @@ -712,8 +742,8 @@
@@ -781,16 +811,29 @@

- {#if isPost} + {#if isBg} + The scene has no background yet + {:else if isPost} No post effect to edit yet {:else} {ownerName} has no shader yet {/if}

- {#if isPost} + {#if isBg} +
+ {#each $backgroundPresets.presets as preset (preset.key)} + + {/each} +
+

+ A background draws behind the scene, or on a plane — Configure Scene ▸ Background +

+ {:else if isPost}
@@ -1002,6 +1045,8 @@ {doc.nodes.length} nodes · {doc.edges.length} wires · {doc.backend}

Replicates to peers · saved with the scene

+ {:else if isBg} +

Pick a preset to start a background, then take it apart here.

{:else if isPost}

Create a post effect, then add it to a look in Configure Scene ▸ diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index e37d9de07..46ceda7bc 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -26,6 +26,7 @@ import MenuButton from './inspector/MenuButton.svelte'; import InsToggle from './inspector/InsToggle.svelte'; import SceneQualitySection from './inspector/SceneQualitySection.svelte'; // 42 I12 + import BackgroundLayerSection from './inspector/BackgroundLayerSection.svelte'; // 65 M2 import ArrangeSection from './inspector/ArrangeSection.svelte'; // 47 M2 import TerrainPaintSection from './inspector/TerrainPaintSection.svelte'; // 47 T1/T2 import MeasureSection from './inspector/MeasureSection.svelte'; // 48 B2 @@ -2122,6 +2123,7 @@ Open the shader editor +

@@ -45,6 +45,16 @@ In Local space the ribbon rides the object. Set Space to World for a trail behind it.

{/if} + + set({ flow: v })} /> + {#if (p.flow ?? 0) > 0} + set({ flowScale: v })} /> + set({ flowSpeed: v })} /> + {/if} {#if world} set({ inherit: v })} /> diff --git a/src/lib/particlePresets.js b/src/lib/particlePresets.js index d7e3a0e5e..609c64bb8 100644 --- a/src/lib/particlePresets.js +++ b/src/lib/particlePresets.js @@ -30,6 +30,8 @@ // particles in birth order — with space 'world', the path the emitter took; continuous // emitters only, a burst falls back to trails); inherit = share of the emitter's velocity // a particle is born with (world space; 1 = all of it) +// 65 M6: flow = curl-noise flow field strength m/s (0 = off), flowScale = its frequency +// (1/m — higher = tighter swirls), flowSpeed = how fast the field itself changes /** @type {any} */ export const PARTICLE_DEFAULTS = { @@ -62,7 +64,10 @@ export const PARTICLE_DEFAULTS = { inherit: 0, stretch: 0.04, trail: 0.4, - trailSegments: 8 + trailSegments: 8, + flow: 0, + flowScale: 0.6, + flowSpeed: 0.3 }; // The Core 6 (user-locked lineup). Names show in menus; keys are stable ids. @@ -210,6 +215,37 @@ export const PARTICLE_PRESETS = [ sprite: 'dot', blending: 'normal', spin: 0.5, space: 'world', wind: [0.35, 0, 0.12], fall: 6, ground: 'settle' } + }, + // 65 M6: curl-noise flow fields — trails that swirl through a divergence-free field + { + key: 'flowfield', + name: 'Flow field', + config: { + count: 360, lifetime: 6, lifeJitter: 0.3, + shape: 'box', area: [4, 4], angle: 80, radius: 0, + speed: 0.05, speedJitter: 0.5, gravity: 0, drag: 0, turbulence: 0, + sizeStart: 0.02, sizeEnd: 0.012, + colorStart: '#7ff9ff', colorEnd: '#b06bff', + opacity: 0.85, fadeIn: 0.15, fadeOut: 0.4, + sprite: 'dot', blending: 'additive', spin: 0, space: 'world', + render: 'trails', trail: 1.2, trailSegments: 12, + flow: 1.1, flowScale: 0.7, flowSpeed: 0.25 + } + }, + { + key: 'swirl', + name: 'Swirl ribbon', + config: { + count: 160, lifetime: 2.6, lifeJitter: 0, + shape: 'sphere', radius: 0.05, + speed: 0.15, speedJitter: 0.2, gravity: 0.1, drag: 0.4, turbulence: 0, + sizeStart: 0.06, sizeEnd: 0.01, + colorStart: '#ffd1f5', colorEnd: '#5b6cff', + opacity: 0.8, fadeIn: 0.05, fadeOut: 0.6, + sprite: 'dot', blending: 'additive', spin: 0, space: 'world', + render: 'ribbon', inherit: 0.4, + flow: 0.8, flowScale: 0.9, flowSpeed: 0.4 + } } ]; diff --git a/src/lib/particleRuntime.js b/src/lib/particleRuntime.js index 1d5020f8c..fcced70d9 100644 --- a/src/lib/particleRuntime.js +++ b/src/lib/particleRuntime.js @@ -185,6 +185,11 @@ function buildEntry(key, count, render = 'points', segs = 1) { uWind: { value: new THREE.Vector3() }, uFall: { value: 0 }, uGround: { value: 0 }, + // 65 M6: the curl-noise flow field + uFlow: { value: 0 }, + uFlowScale: { value: 0.6 }, + uFlowSpeed: { value: 0.3 }, + uFlowSteps: { value: 4 }, uQuat: { value: new THREE.Vector4(0, 0, 0, 1) }, uWorldSpace: { value: 0 }, // 37-fx: inherit velocity + the strip modes @@ -360,6 +365,10 @@ function applyUniforms(entry, cfg) { u.uGravity.value = cfg.gravity; u.uDrag.value = cfg.drag; u.uTurbulence.value = cfg.turbulence; + // 65 M6: curl-noise flow (clamped: a field faster than this reads as noise, not flow) + u.uFlow.value = Math.min(Math.max(Number(cfg.flow) || 0, 0), 6); + u.uFlowScale.value = Math.min(Math.max(Number(cfg.flowScale ?? 0.6) || 0.6, 0.05), 8); + u.uFlowSpeed.value = Math.min(Math.max(Number(cfg.flowSpeed ?? 0.3) || 0, 0), 4); // PFX-B fix: `size` is an alias for the start size (the wired input handle) u.uSizeStart.value = cfg.size ?? cfg.sizeStart; u.uSizeEnd.value = cfg.sizeEnd; @@ -479,7 +488,7 @@ export function updateParticles(pairs, sceneObjects, time) { const sig = JSON.stringify([ cfg.offset, cfg.speed, cfg.gravity, cfg.sizeStart, cfg.size, cfg.lifetime, cfg.turbulence, cfg.angle, cfg.radius, cfg.shape, cfg.sprite, cfg.colorStart, cfg.colorEnd, - cfg.inherit, cfg.stretch, cfg.trail + cfg.inherit, cfg.stretch, cfg.trail, cfg.flow, cfg.flowScale, cfg.flowSpeed ]); if (entry.cfgSig !== undefined && entry.cfgSig !== sig) { entry.burstT = tw; @@ -488,6 +497,9 @@ export function updateParticles(pairs, sceneObjects, time) { entry.cfgSig = sig; } u.uBurstT.value = entry.burstT; + // 65 M6: the device budget — half the flow integration on a headset or once the governor + // caps particles (a flow path is a few trig calls per step per vertex) + u.uFlowSteps.value = vr || particlesCapped ? 2 : 4; const drawn = vr ? Math.min(cfg.count, VR_MAX_COUNT) : cfg.count; entry.geometry.setDrawRange(0, entry.layout ? drawn * entry.layout.indicesPerSlot : drawn); entry.points.visible = object.visible !== false; diff --git a/src/lib/particleShader.js b/src/lib/particleShader.js index b2eab6c41..c53b471b8 100644 --- a/src/lib/particleShader.js +++ b/src/lib/particleShader.js @@ -48,6 +48,41 @@ const SIM_COMMON = /* glsl */ ` uniform vec3 uWind; // 36 B3: constant drift, m/s uniform float uFall; // 36 B3: the ground this far below the emitter (0 = none) uniform float uGround; // 36 B3: on landing 1 = splash (rain), 2 = settle (snow) + uniform float uFlow; // 65 M6: curl-noise flow strength, m/s (0 = off) + uniform float uFlowScale; // ...the field's frequency, 1/m + uniform float uFlowSpeed; // ...how fast the field itself evolves + uniform float uFlowSteps; // ...integration steps (the device budget: 4, 2 on a headset / capped) + + // 65 M6 — CURL NOISE: the curl of a smooth vector potential is DIVERGENCE-FREE, so the + // particles swirl like smoke or ink without bunching up or thinning out. The potential is + // two octaves of trigonometric waves with ANALYTIC derivatives (no noise texture, no + // finite differences), a pure function of (position, synced time) like everything else in + // this shader — so every peer, and every trail segment sampling the same particle at an + // earlier age, sees the same path. + vec3 tpCurlOctave(vec3 p, float t) { + return vec3( + -1.9 * sin(1.9 * p.y + 1.1 * t) - 1.1 * cos(1.1 * p.z + 0.9 * t), + -1.7 * sin(1.7 * p.z - 0.7 * t) - 1.2 * cos(1.2 * p.x - 0.8 * t), + -1.5 * sin(1.5 * p.x + 0.6 * t) - 1.3 * cos(1.3 * p.y + t)); + } + vec3 tpCurl(vec3 p, float t) { + return (tpCurlOctave(p, t) + 0.5 * tpCurlOctave(p * 2.1 + vec3(3.1, 1.7, 5.3), t * 1.3)) / 4.5; + } + // how far the field has carried a particle by \`age\`: integrated along the straight line + // from where it spawned (s0) to where its ballistic motion puts it now (s1), in uFlowSteps + // midpoint steps — the step count scales with nothing but the budget, so a longer life is + // a longer step, never a different number of them (and a trail stays continuous) + vec3 tpFlowOffset(vec3 s0, vec3 s1, float birth, float age) { + vec3 d = vec3(0.0); + float h = age / uFlowSteps; + for (int i = 0; i < 6; i++) { + if (float(i) >= uFlowSteps) break; + float f = (float(i) + 0.5) / uFlowSteps; + vec3 q = mix(s0, s1, f) + d; + d += tpCurl(q * uFlowScale, (birth + age * f) * uFlowSpeed) * (uFlow * h); + } + return d; + } vec3 qrot(vec4 q, vec3 v) { return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); @@ -140,6 +175,13 @@ const SIM_COMMON = /* glsl */ ` landed = uGround > 1.5 ? 1.0 : clamp((age - hitAge) / 0.18, 0.0, 1.0) + 0.001; } if (uWorldSpace > 0.5) p = origin + qrot(uQuat, p); + // 65 M6: the flow field — in WORLD space for a world emitter (the field stays put and + // the particles drift through it), in the emitter's frame otherwise; a landed particle + // stays where it landed + if (uFlow > 0.001 && landed <= 0.0) { + vec3 s0 = uWorldSpace > 0.5 ? origin + qrot(uQuat, posBase) : posBase; + p += tpFlowOffset(s0, p, uTime - age, age); + } return p; } `; From c223d530cac86074799bb5fa83730bb325016896 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:52:01 +0300 Subject: [PATCH 4/8] =?UTF-8?q?65=20M6=20phase=205=20(WIP):=20fluidSim.js?= =?UTF-8?q?=20=E2=80=94=20own=20stable-fluids=20solver=20(not=20wired=20ye?= =?UTF-8?q?t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/fluidSim.js | 386 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 src/lib/fluidSim.js diff --git a/src/lib/fluidSim.js b/src/lib/fluidSim.js new file mode 100644 index 000000000..d9ccc7f24 --- /dev/null +++ b/src/lib/fluidSim.js @@ -0,0 +1,386 @@ +// 65 M6 — a 2D STABLE-FLUIDS solver on the GPU, for the smoke/ink layer (fluidLayer.js). +// +// Our own code, written from the published method (Stam, "Stable Fluids", SIGGRAPH 1999, and +// the GPU formulation in GPU Gems ch. 38); Pavel Dobryakov's WebGL-Fluid-Simulation (MIT) was +// the REFERENCE for which passes are worth running in a browser (vorticity confinement, a few +// Jacobi pressure iterations, a decaying pressure field) — no code was copied from it. +// +// One step, in order: curl -> vorticity confinement (puts the swirls back that the coarse +// grid numerically damps) -> divergence -> pressure (decayed, then Jacobi) -> subtract the +// pressure gradient (the field is now divergence-free) -> advect the velocity -> advect the +// dye. Splats add velocity + dye where the pointer or a controller moved. Every field is a +// half-float ping-pong pair; the velocity is in sim TEXELS per second, so the solver behaves +// the same at every resolution the device budget picks. +// +// LOCAL by nature: the sim is each viewer's own (their pointer stirs it), like the 36-sim +// fluid particles. Only the layer's SETTINGS are scene data (the look entry). + +import * as THREE from 'three'; + +const VERTEX = /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = position.xy * 0.5 + 0.5; + gl_Position = vec4(position.xy, 0.0, 1.0); + } +`; + +// every pass reads its four neighbours one texel away +const NEIGHBOURS = /* glsl */ ` + varying vec2 vUv; + uniform vec2 uTexel; + vec2 L() { return vUv - vec2(uTexel.x, 0.0); } + vec2 R() { return vUv + vec2(uTexel.x, 0.0); } + vec2 B() { return vUv - vec2(0.0, uTexel.y); } + vec2 T() { return vUv + vec2(0.0, uTexel.y); } +`; + +const SPLAT = /* glsl */ ` + varying vec2 vUv; + uniform sampler2D uTarget; + uniform float uAspect; + uniform vec2 uPoint; + uniform vec3 uColor; + uniform float uRadius; + void main() { + vec2 p = vUv - uPoint; + p.x *= uAspect; + vec3 base = texture2D(uTarget, vUv).xyz; + gl_FragColor = vec4(base + exp(-dot(p, p) / uRadius) * uColor, 1.0); + } +`; + +const ADVECT = /* glsl */ ` + varying vec2 vUv; + uniform sampler2D uVelocity; + uniform sampler2D uSource; + uniform vec2 uTexel; + uniform float uDt; + uniform float uDissipation; + void main() { + // trace back along the velocity (texels/s) and read what was there — unconditionally stable + vec2 from = vUv - uDt * texture2D(uVelocity, vUv).xy * uTexel; + vec4 value = texture2D(uSource, from); + gl_FragColor = value / (1.0 + uDissipation * uDt); + } +`; + +const DIVERGENCE = /* glsl */ ` + ${NEIGHBOURS} + uniform sampler2D uVelocity; + void main() { + float l = texture2D(uVelocity, L()).x; + float r = texture2D(uVelocity, R()).x; + float b = texture2D(uVelocity, B()).y; + float t = texture2D(uVelocity, T()).y; + vec2 c = texture2D(uVelocity, vUv).xy; + // solid walls: the mirrored velocity at the border, so nothing flows out of the frame + if (L().x < 0.0) l = -c.x; + if (R().x > 1.0) r = -c.x; + if (B().y < 0.0) b = -c.y; + if (T().y > 1.0) t = -c.y; + gl_FragColor = vec4(0.5 * (r - l + t - b), 0.0, 0.0, 1.0); + } +`; + +const CURL = /* glsl */ ` + ${NEIGHBOURS} + uniform sampler2D uVelocity; + void main() { + float l = texture2D(uVelocity, L()).y; + float r = texture2D(uVelocity, R()).y; + float b = texture2D(uVelocity, B()).x; + float t = texture2D(uVelocity, T()).x; + gl_FragColor = vec4(0.5 * (r - l - t + b), 0.0, 0.0, 1.0); + } +`; + +const VORTICITY = /* glsl */ ` + ${NEIGHBOURS} + uniform sampler2D uVelocity; + uniform sampler2D uCurl; + uniform float uStrength; + uniform float uDt; + void main() { + float l = texture2D(uCurl, L()).x; + float r = texture2D(uCurl, R()).x; + float b = texture2D(uCurl, B()).x; + float t = texture2D(uCurl, T()).x; + float c = texture2D(uCurl, vUv).x; + vec2 force = 0.5 * vec2(abs(t) - abs(b), abs(r) - abs(l)); + force /= length(force) + 0.0001; + force *= uStrength * c; + force.y *= -1.0; + vec2 v = texture2D(uVelocity, vUv).xy + force * uDt; + gl_FragColor = vec4(clamp(v, -1000.0, 1000.0), 0.0, 1.0); + } +`; + +const SCALE = /* glsl */ ` + varying vec2 vUv; + uniform sampler2D uSource; + uniform float uScale; + void main() { gl_FragColor = uScale * texture2D(uSource, vUv); } +`; + +const PRESSURE = /* glsl */ ` + ${NEIGHBOURS} + uniform sampler2D uPressure; + uniform sampler2D uDivergence; + void main() { + float l = texture2D(uPressure, L()).x; + float r = texture2D(uPressure, R()).x; + float b = texture2D(uPressure, B()).x; + float t = texture2D(uPressure, T()).x; + float d = texture2D(uDivergence, vUv).x; + gl_FragColor = vec4((l + r + b + t - d) * 0.25, 0.0, 0.0, 1.0); + } +`; + +const GRADIENT = /* glsl */ ` + ${NEIGHBOURS} + uniform sampler2D uPressure; + uniform sampler2D uVelocity; + void main() { + float l = texture2D(uPressure, L()).x; + float r = texture2D(uPressure, R()).x; + float b = texture2D(uPressure, B()).x; + float t = texture2D(uPressure, T()).x; + vec2 v = texture2D(uVelocity, vUv).xy - vec2(r - l, t - b); + gl_FragColor = vec4(v, 0.0, 1.0); + } +`; + +/** @param {number} w @param {number} h @param {any} type */ +function target(w, h, type) { + const rt = new THREE.WebGLRenderTarget(w, h, { + type, + format: THREE.RGBAFormat, + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + wrapS: THREE.ClampToEdgeWrapping, + wrapT: THREE.ClampToEdgeWrapping, + depthBuffer: false, + stencilBuffer: false + }); + rt.texture.generateMipmaps = false; + return rt; +} + +/** a ping-pong pair: read from `read`, render into `write`, then swap */ +class Pair { + /** @param {number} w @param {number} h @param {any} type */ + constructor(w, h, type) { + this.read = target(w, h, type); + this.write = target(w, h, type); + this.w = w; + this.h = h; + } + swap() { + const t = this.read; + this.read = this.write; + this.write = t; + } + dispose() { + this.read.dispose(); + this.write.dispose(); + } +} + +/** + * @typedef {{simRes: number, dyeRes: number, iterations: number}} FluidQuality + * @typedef {{curl: number, velocityFade: number, dyeFade: number, pressureDecay: number, radius: number}} FluidPhysics + */ + +export class FluidSim { + /** @param {FluidQuality} quality @param {number} aspect width / height of the layer */ + constructor(quality, aspect) { + this.quality = { ...quality }; + this.aspect = aspect; + this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + this.scene = new THREE.Scene(); + this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); + this.quad.frustumCulled = false; + this.scene.add(this.quad); + /** @type {Record} */ + this.mats = {}; + const pass = (/** @type {string} */ name, /** @type {string} */ frag, /** @type {Record} */ uniforms) => { + this.mats[name] = new THREE.ShaderMaterial({ + vertexShader: VERTEX, + fragmentShader: frag, + uniforms, + depthTest: false, + depthWrite: false, + toneMapped: false + }); + }; + const tex = () => ({ value: null }); + const v2 = () => ({ value: new THREE.Vector2() }); + pass('splat', SPLAT, { uTarget: tex(), uAspect: { value: 1 }, uPoint: v2(), uColor: { value: new THREE.Vector3() }, uRadius: { value: 0.002 } }); + pass('advect', ADVECT, { uVelocity: tex(), uSource: tex(), uTexel: v2(), uDt: { value: 0 }, uDissipation: { value: 0 } }); + pass('divergence', DIVERGENCE, { uVelocity: tex(), uTexel: v2() }); + pass('curl', CURL, { uVelocity: tex(), uTexel: v2() }); + pass('vorticity', VORTICITY, { uVelocity: tex(), uCurl: tex(), uTexel: v2(), uStrength: { value: 0 }, uDt: { value: 0 } }); + pass('scale', SCALE, { uSource: tex(), uScale: { value: 1 } }); + pass('pressure', PRESSURE, { uPressure: tex(), uDivergence: tex(), uTexel: v2() }); + pass('gradient', GRADIENT, { uPressure: tex(), uVelocity: tex(), uTexel: v2() }); + this.type = THREE.HalfFloatType; + this.steps = 0; + this.splats = 0; + this.allocate(); + } + + allocate() { + const { simRes, dyeRes } = this.quality; + const size = (/** @type {number} */ res) => { + // the SHORT side is `res`: the long side follows the layer's aspect + const a = Math.max(0.25, Math.min(4, this.aspect || 1)); + return a >= 1 ? [Math.round(res * a), res] : [res, Math.round(res / a)]; + }; + const [sw, sh] = size(simRes); + const [dw, dh] = size(dyeRes); + this.dispose(true); + this.velocity = new Pair(sw, sh, this.type); + this.pressure = new Pair(sw, sh, this.type); + this.divergence = target(sw, sh, this.type); + this.curl = target(sw, sh, this.type); + this.dye = new Pair(dw, dh, this.type); + this.simTexel = new THREE.Vector2(1 / sw, 1 / sh); + this.dyeTexel = new THREE.Vector2(1 / dw, 1 / dh); + this.cleared = false; + } + + /** Change the budget tier or the aspect; reallocates only when something changed. @param {FluidQuality} q @param {number} aspect */ + configure(q, aspect) { + const same = + q.simRes === this.quality.simRes && q.dyeRes === this.quality.dyeRes && Math.abs((aspect || 1) - (this.aspect || 1)) < 0.02; + this.quality = { ...q }; + if (same) return false; + this.aspect = aspect; + this.allocate(); + return true; + } + + /** The dye texture the presenters draw. */ + get texture() { + return this.dye.read.texture; + } + + /** @param {any} renderer @param {any} material @param {any} into a target or null */ + blit(renderer, material, into) { + this.quad.material = material; + renderer.setRenderTarget(into); + renderer.render(this.scene, this.camera); + } + + /** zero every field once after allocation (a new target holds garbage on some drivers) @param {any} renderer */ + clearAll(renderer) { + const color = new THREE.Color(); + renderer.getClearColor(color); + const alpha = renderer.getClearAlpha(); + renderer.setClearColor(0x000000, 0); + for (const rt of [this.velocity.read, this.velocity.write, this.pressure.read, this.pressure.write, this.dye.read, this.dye.write, this.divergence, this.curl]) { + renderer.setRenderTarget(rt); + renderer.clear(true, false, false); + } + renderer.setClearColor(color, alpha); + this.cleared = true; + } + + /** + * Push velocity + dye in at a point. Coordinates are the layer's 0..1 UV; `force` is in + * UV per second (the pointer's own speed), `color` linear RGB. + * @param {any} renderer @param {number} x @param {number} y @param {number} fx @param {number} fy + * @param {number[]} color @param {number} radius in UV (fraction of the short side) + */ + splat(renderer, x, y, fx, fy, color, radius) { + const m = this.mats.splat; + const u = m.uniforms; + u.uAspect.value = this.aspect || 1; + u.uPoint.value.set(x, y); + u.uRadius.value = Math.max(radius * radius, 1e-6); + // velocity: UV/s -> sim texels/s + u.uTarget.value = this.velocity.read.texture; + u.uColor.value.set(fx * this.velocity.w, fy * this.velocity.h, 0); + this.blit(renderer, m, this.velocity.write); + this.velocity.swap(); + u.uTarget.value = this.dye.read.texture; + u.uColor.value.set(color[0], color[1], color[2]); + this.blit(renderer, m, this.dye.write); + this.dye.swap(); + this.splats++; + } + + /** One solver step. @param {any} renderer @param {number} dt seconds @param {FluidPhysics} phys */ + step(renderer, dt, phys) { + const M = this.mats; + const st = this.simTexel; + M.curl.uniforms.uVelocity.value = this.velocity.read.texture; + M.curl.uniforms.uTexel.value.copy(st); + this.blit(renderer, M.curl, this.curl); + + const vort = M.vorticity.uniforms; + vort.uVelocity.value = this.velocity.read.texture; + vort.uCurl.value = this.curl.texture; + vort.uTexel.value.copy(st); + vort.uStrength.value = phys.curl; + vort.uDt.value = dt; + this.blit(renderer, M.vorticity, this.velocity.write); + this.velocity.swap(); + + M.divergence.uniforms.uVelocity.value = this.velocity.read.texture; + M.divergence.uniforms.uTexel.value.copy(st); + this.blit(renderer, M.divergence, this.divergence); + + // the last step's pressure, decayed, is the starting guess: far fewer iterations needed + M.scale.uniforms.uSource.value = this.pressure.read.texture; + M.scale.uniforms.uScale.value = phys.pressureDecay; + this.blit(renderer, M.scale, this.pressure.write); + this.pressure.swap(); + const pr = M.pressure.uniforms; + pr.uDivergence.value = this.divergence.texture; + pr.uTexel.value.copy(st); + for (let i = 0; i < this.quality.iterations; i++) { + pr.uPressure.value = this.pressure.read.texture; + this.blit(renderer, M.pressure, this.pressure.write); + this.pressure.swap(); + } + + const gr = M.gradient.uniforms; + gr.uPressure.value = this.pressure.read.texture; + gr.uVelocity.value = this.velocity.read.texture; + gr.uTexel.value.copy(st); + this.blit(renderer, M.gradient, this.velocity.write); + this.velocity.swap(); + + const ad = M.advect.uniforms; + ad.uDt.value = dt; + ad.uVelocity.value = this.velocity.read.texture; + ad.uSource.value = this.velocity.read.texture; + ad.uTexel.value.copy(st); + ad.uDissipation.value = phys.velocityFade; + this.blit(renderer, M.advect, this.velocity.write); + this.velocity.swap(); + // the dye moves on the SAME velocity field, measured in sim texels: on the finer dye grid + // one sim texel is several dye texels, so the trace-back uses the sim texel size + ad.uVelocity.value = this.velocity.read.texture; + ad.uSource.value = this.dye.read.texture; + ad.uDissipation.value = phys.dyeFade; + this.blit(renderer, M.advect, this.dye.write); + this.dye.swap(); + this.steps++; + } + + /** @param {boolean} [keepPasses] */ + dispose(keepPasses = false) { + this.velocity?.dispose(); + this.pressure?.dispose(); + this.dye?.dispose(); + this.divergence?.dispose(); + this.curl?.dispose(); + if (!keepPasses) { + for (const m of Object.values(this.mats)) m.dispose(); + this.quad.geometry.dispose(); + } + } +} From 5db97857a6a8b9362126ff8020e661a01675ae97 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 17:32:06 +0300 Subject: [PATCH 5/8] =?UTF-8?q?65=20M6=20phase=205:=20the=20smoke=20/=20in?= =?UTF-8?q?k=20FLUID=20LAYER=20=E2=80=94=20a=20scene-look=20entry=20on=20s?= =?UTF-8?q?creens,=20a=20world=20panel=20in=20VR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fluidLayer.js: one shared FluidSim, stirred by the local pointer (or the VR controller rays) and an optional automatic stirrer. Look kind `fluid` (Stylize): its Effect steps the solver in update() and composites the dye (smoke adds light, ink mixes pigment). In a headset (no composer) the same entry becomes a panel ~3 m ahead under background-root, stepped from the Scene useTask with xr.enabled off for the passes. Nothing composited for 500 ms = no steps; 10 s = targets freed. - fluidLayerCore.js (pure): params normalize, the budget tiers (128/512/20 desktop, 96/384/14 at dpr <= 0.72, 64/256/8 phone/XR, none in XR once particles are capped), palettes, the auto stirrer, pointer -> splat. - fluidSim.js: fields declared for the checker. backgroundLayer: backgroundRootGroup(). - particleEntries() reports flow / flowSteps. debugHooks row fluidLayer. - tests: unit fluidLayer (15), e2e fluid-layer (desktop) + visuals-xr-iwer (Quest profile). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 2 + src/lib/backgroundLayer.js | 5 + src/lib/debugHooks.js | 1 + src/lib/fluidLayer.js | 473 +++++++++++++++++++++++++++++ src/lib/fluidLayerCore.js | 138 +++++++++ src/lib/fluidSim.js | 9 + src/lib/particleRuntime.js | 2 + tests/e2e/fluid-layer.test.cjs | 174 +++++++++++ tests/e2e/visuals-xr-iwer.test.cjs | 115 +++++++ tests/unit/fluidLayer.test.js | 116 +++++++ 10 files changed, 1035 insertions(+) create mode 100644 src/lib/fluidLayer.js create mode 100644 src/lib/fluidLayerCore.js create mode 100644 tests/e2e/fluid-layer.test.cjs create mode 100644 tests/e2e/visuals-xr-iwer.test.cjs create mode 100644 tests/unit/fluidLayer.test.js diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index ce68f8c4d..86983ecbb 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -73,6 +73,7 @@ import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler'; import { setParticleRoot } from '$lib/particleRuntime'; import { setBackgroundRoot, tickBackgroundLayer } from '$lib/backgroundLayer'; // 65 M2 + import { tickFluidLayer } from '$lib/fluidLayer'; // 65 M6: smoke / ink (a look entry; the XR panel) import WaterLayer from './water/WaterLayer.svelte'; import { registerEditResumeSources } from '$lib/editResume'; import { sendPing } from '$lib/ping'; @@ -428,6 +429,7 @@ tickPackBehaviors(); // 33 P2: doors/lids/levers pose from their shared state (rest in Edit) tickSim($objectsGroup, camera.current, renderer, performance.now()); // 36-sim (after mixers: jiggle rides the posed bones) tickBackgroundLayer(camera.current, renderer, performance.now()); // 65 M2: the shader background (inert without one) + tickFluidLayer(camera.current, renderer, performance.now()); // 65 M6: steps the XR smoke panel (the screen effect steps itself) tickAnimationPreview(); // Animation window: local transform preview (not synced) tickSequences(); // 48 S2: shots / clips / audio / events of running sequences tickMeshEdit(); // vertex handles follow the object if it moves (119) diff --git a/src/lib/backgroundLayer.js b/src/lib/backgroundLayer.js index 15a5e0b2a..d2f48f3a8 100644 --- a/src/lib/backgroundLayer.js +++ b/src/lib/backgroundLayer.js @@ -247,6 +247,11 @@ function ensureMeshes() { refreshDraw(); } +/** The scene-root group the layer draws under (65 M6's XR fluid panel shares it). */ +export function backgroundRootGroup() { + return root; +} + /** Scene.svelte hands over the scene-root group. @param {any} group */ export function setBackgroundRoot(group) { root = group; diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 03cfca268..ab9d9bba2 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -394,6 +394,7 @@ export const DEBUG_HOOKS = [ // 65 M2: the shader background layer + its packs preset loader ['backgroundLayer', () => import('./backgroundLayer')], ['backgroundPresets', () => import('./backgroundPresets')], + ['fluidLayer', () => import('./fluidLayer')], ['postGraphPresets', () => import('./postGraphPresets')], ['materialSharing', () => import('./materialSharing')], ['diagnostics', () => import('./diagnostics')], diff --git a/src/lib/fluidLayer.js b/src/lib/fluidLayer.js new file mode 100644 index 000000000..2e275ae7c --- /dev/null +++ b/src/lib/fluidLayer.js @@ -0,0 +1,473 @@ +// 65 M6 — the SMOKE / INK FLUID LAYER: one shared stable-fluids sim (fluidSim.js), stirred by +// the pointer (or the VR controllers) and an optional automatic stirrer, shown two ways. +// +// ON A SCREEN it is a scene-look entry, kind `fluid` (registerPostEffect): the entry is SCENE +// DATA like every look entry — it replicates, saves and undoes with the look — and its Effect +// both steps the solver (in `update`, where postprocessing's own Bloom renders its mips) and +// composites the dye over the finished frame: smoke ADDS light, ink MIXES pigment in. +// +// IN A HEADSET the composer does not run (Outline renders the XR frame directly), and a +// screen-space layer would be head-locked anyway. So the same entry becomes a WORLD PANEL +// about 3 m in front of where you stood when the session started, under the scene-root +// `background-root` group (never in sceneObjects — the particle-root rule), stepped from the +// Scene useTask (tickFluidLayer) with `renderer.xr.enabled` off while the passes render, and +// stirred by whichever controller ray crosses it. +// +// LOCAL by nature: the sim, its pointer and the automatic stirrer are each viewer's own — the +// smoke is not the same swirl on two screens, the 36-sim fluid-particles rule. Only the +// entry's params are shared. +// +// THE BUDGET (fluidLayerCore.fluidTierFor): grid size and Jacobi iterations drop on a +// governor-shrunk desktop, again on a phone or a headset, and a headset whose governor is +// already capping particles gets no fluid at all. Nothing composited the dye for ~500 ms (the +// entry was switched off, the look disabled here, the tab hidden) = nothing steps; after 10 s +// the GPU targets are freed. + +import * as THREE from 'three'; +import { get, writable } from 'svelte/store'; +import { Effect, BlendFunction } from 'postprocessing'; +import { FluidSim } from './fluidSim.js'; +import { + FLUID_DEFAULTS, + FLUID_PALETTES, + normalizeFluidParams, + fluidTierFor, + physicsFor, + dyeColor, + autoStir, + pointerSplat +} from './fluidLayerCore.js'; +import { registerPostEffect, scenePost } from './scenePost.js'; +import { viewportOverrides } from './viewportOverrides.js'; +import { qualityOverrides, phoneQuality } from './qualityGovernor.js'; +import { globalRenderer } from '../stores/sceneStore.js'; +import { ndcFromClient } from './canvasRect.js'; +import { backgroundRootGroup } from './backgroundLayer.js'; +import { controllerRay } from './vr/pointer.js'; + +/** The look entry's kind. */ +export const FLUID_KIND = 'fluid'; +/** Nothing composited for this long = stop stepping. */ +const IDLE_MS = 500; +/** ... and for this long = free the targets. */ +const FREE_MS = 10000; +/** The XR panel: width x height in metres, and how far ahead of the viewer it is put. */ +const PANEL_W = 3.2; +const PANEL_H = 1.8; +const PANEL_DIST = 3; +/** Velocity a pointer splat carries, per UV/s of pointer speed. */ +const FORCE = 1.6; + +/** + * What the layer is doing on THIS device (debug hook + e2e seam). + * @type {import('svelte/store').Writable<{presenter: 'none'|'post'|'panel', tier: string, simRes: number, dyeRes: number, iterations: number, steps: number, splats: number}>} + */ +export const fluidState = writable({ presenter: 'none', tier: 'off', simRes: 0, dyeRes: 0, iterations: 0, steps: 0, splats: 0 }); + +// ---- the one shared sim ----------------------------------------------------------------- + +/** @type {FluidSim|null} */ +let sim = null; +let lastStep = 0; +let lastUsed = 0; +/** @type {'none'|'post'|'panel'} */ +let presenter = 'none'; +/** splats waiting for the next step @type {{x: number, y: number, vx: number, vy: number}[]} */ +const queue = []; +/** a pointer splat's colour phase, so a stroke drifts through the palette */ +let phase = 0; +/** test seam: hold the automatic stirrer still @type {boolean} */ +let autoHeld = false; + +/** @param {import('./fluidLayerCore.js').FluidTier} tier @param {number} aspect */ +function ensureSim(tier, aspect) { + if (!sim) sim = new FluidSim(tier, aspect); + else sim.configure(tier, aspect); + return sim; +} + +function freeSim() { + sim?.dispose(); + sim = null; + publish(); +} + +function publish() { + fluidState.set({ + presenter, + tier: sim ? currentTier?.tier ?? 'off' : 'off', + simRes: sim?.quality.simRes ?? 0, + dyeRes: sim?.quality.dyeRes ?? 0, + iterations: sim?.quality.iterations ?? 0, + steps: sim?.steps ?? 0, + splats: sim?.splats ?? 0 + }); +} + +/** @type {import('./fluidLayerCore.js').FluidTier|null} */ +let currentTier = null; + +function tierNow(/** @type {boolean} */ xr) { + const q = get(qualityOverrides); + return fluidTierFor({ xr, phone: phoneQuality(), dprScale: q.dprScale, particlesCapped: q.particlesCapped }); +} + +/** + * Splats in, one solver step. Saves and restores the render target, and switches XR off for + * the passes (three would otherwise swap in the XR camera and draw each pass twice, once per + * eye, into the eye viewports). + * @param {any} renderer @param {import('./fluidLayerCore.js').FluidParams} params @param {number} now ms + */ +function stepShared(renderer, params, now) { + if (!sim) return; + // the composer may render more than once a frame (picture-in-picture views): step once + if (lastStep && now - lastStep < 4) return; + const dt = lastStep ? Math.min((now - lastStep) / 1000, 1 / 30) : 1 / 60; + lastStep = now; + lastUsed = now; + const prevTarget = renderer.getRenderTarget(); + const xrOn = renderer.xr.enabled; + const autoClear = renderer.autoClear; + renderer.xr.enabled = false; + renderer.autoClear = false; + try { + if (!sim.cleared) sim.clearAll(renderer); + const t = now / 1000; + const radius = params.radius * 0.1; + if (params.auto && !autoHeld) { + const s = autoStir(t); + sim.splat(renderer, s.x, s.y, s.vx * FORCE * 0.5, s.vy * FORCE * 0.5, scaled(dyeColor(params.palette, t), 0.35), radius); + } + for (const s of queue.splice(0, 16)) { + phase += 0.05; + sim.splat(renderer, s.x, s.y, s.vx * FORCE, s.vy * FORCE, dyeColor(params.palette, t + phase), radius); + } + queue.length = 0; + sim.step(renderer, dt, physicsFor(params)); + } finally { + renderer.setRenderTarget(prevTarget); + renderer.xr.enabled = xrOn; + renderer.autoClear = autoClear; + } + publish(); +} + +/** @param {number[]} c @param {number} k */ +const scaled = (c, k) => [c[0] * k, c[1] * k, c[2] * k]; + +// ---- local pointer input ---------------------------------------------------------------- + +let listening = false; +/** @type {{x: number, y: number, t: number}|null} */ +let lastPointer = null; + +function listen() { + if (listening || typeof window === 'undefined') return; + listening = true; + window.addEventListener( + 'pointermove', + (e) => { + if (presenter !== 'post') return; + const canvas = get(globalRenderer)?.domElement; + if (canvas && e.target !== canvas) { + lastPointer = null; + return; + } + const p = ndcFromClient(e.clientX, e.clientY); + const at = { x: p.x * 0.5 + 0.5, y: p.y * 0.5 + 0.5, t: e.timeStamp || performance.now() }; + if (at.x < 0 || at.x > 1 || at.y < 0 || at.y > 1) { + lastPointer = null; + return; + } + if (lastPointer) { + const s = pointerSplat(lastPointer, at, (at.t - lastPointer.t) / 1000); + if (s && queue.length < 64) queue.push(s); + } + lastPointer = at; + }, + { passive: true } + ); +} + +// ---- ON A SCREEN: the look entry --------------------------------------------------------- + +const COMPOSITE = /* glsl */ ` + uniform sampler2D uDye; + uniform float uIntensity; + uniform float uInk; + void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { + vec3 dye = texture2D(uDye, uv).rgb * uIntensity; + float peak = max(dye.r, max(dye.g, dye.b)); + // smoke: light, soft-clipped so a dense patch saturates instead of blowing out + vec3 smoke = inputColor.rgb + (1.0 - exp(-3.0 * max(dye, vec3(0.0)))); + // ink: the dye's hue as a dark pigment, covering in proportion to how much is there + float cover = 1.0 - exp(-4.0 * max(peak, 0.0)); + vec3 pigment = (dye / max(peak, 1e-3)) * 0.35; + vec3 ink = mix(inputColor.rgb, pigment, cover); + outputColor = vec4(mix(smoke, ink, uInk), inputColor.a); + } +`; + +class FluidEffect extends Effect { + /** @param {import('./fluidLayerCore.js').FluidParams} params */ + constructor(params) { + super('FluidLayerEffect', COMPOSITE, { + blendFunction: BlendFunction.NORMAL, + uniforms: new Map(/** @type {[string, THREE.Uniform][]} */ ([ + ['uDye', new THREE.Uniform(null)], + ['uIntensity', new THREE.Uniform(params.intensity)], + ['uInk', new THREE.Uniform(params.style === 'ink' ? 1 : 0)] + ])) + }); + this.fluidParams = params; + } + + /** @param {any} renderer @param {any} inputBuffer */ + update(renderer, inputBuffer) { + const now = performance.now(); + const tier = tierNow(false); + currentTier = tier; + const u = /** @type {any} */ (this.uniforms); + if (!tier) { + u.get('uDye').value = null; + return; + } + const aspect = inputBuffer?.width && inputBuffer?.height ? inputBuffer.width / inputBuffer.height : 1; + ensureSim(tier, aspect); + presenter = 'post'; + stepShared(renderer, this.fluidParams, now); + u.get('uDye').value = sim?.texture ?? null; + } +} + +registerPostEffect(FLUID_KIND, { + label: 'Smoke / ink', + group: 'stylize', + params: [ + { key: 'style', label: 'Style', type: 'select', default: FLUID_DEFAULTS.style, + options: [ + { value: 'smoke', label: 'Smoke (adds light)' }, + { value: 'ink', label: 'Ink (mixes pigment in)' } + ] }, + { key: 'palette', label: 'Colours', type: 'select', default: FLUID_DEFAULTS.palette, + options: FLUID_PALETTES.map((p) => ({ value: p, label: p[0].toUpperCase() + p.slice(1) })) }, + { key: 'intensity', label: 'Intensity', min: 0, max: 3, step: 0.05, decimals: 2, default: FLUID_DEFAULTS.intensity }, + { key: 'curl', label: 'Swirl', min: 0, max: 60, step: 1, decimals: 0, default: FLUID_DEFAULTS.curl, + hint: 'How strongly the flow curls into eddies.' }, + { key: 'fade', label: 'Fade', min: 0, max: 5, step: 0.05, decimals: 2, default: FLUID_DEFAULTS.fade, + hint: 'How fast the smoke or ink thins out. 0 keeps it.' }, + { key: 'radius', label: 'Brush size', min: 0.05, max: 1, step: 0.01, decimals: 2, default: FLUID_DEFAULTS.radius }, + { key: 'auto', label: 'Stir by itself', type: 'bool', default: FLUID_DEFAULTS.auto, + hint: 'A slow automatic stir, so the layer moves with nobody touching it. Your pointer (or a controller in VR) stirs your own view.' } + ], + make: (params) => { + listen(); + return new FluidEffect(normalizeFluidParams(params)); + } +}); + +// ---- IN A HEADSET: the world panel ------------------------------------------------------- + +/** @type {any} */ +let panel = null; +let panelPlaced = false; +const lastHit = /** @type {({x: number, y: number, t: number}|null)[]} */ ([null, null]); +const headPos = new THREE.Vector3(); +const headDir = new THREE.Vector3(); + +function ensurePanel() { + if (panel) return panel; + const material = new THREE.ShaderMaterial({ + uniforms: { uDye: { value: null }, uIntensity: { value: 1 }, uInk: { value: 0 } }, + vertexShader: /* glsl */ ` + varying vec2 vUv; + void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } + `, + fragmentShader: /* glsl */ ` + uniform sampler2D uDye; + uniform float uIntensity; + uniform float uInk; + varying vec2 vUv; + void main() { + vec3 dye = max(texture2D(uDye, vUv).rgb * uIntensity, vec3(0.0)); + float peak = max(dye.r, max(dye.g, dye.b)); + // soft edges, so the panel reads as a cloud and not a screen + vec2 e = smoothstep(vec2(0.0), vec2(0.12), vUv) * smoothstep(vec2(0.0), vec2(0.12), 1.0 - vUv); + float edge = e.x * e.y; + vec3 smoke = (1.0 - exp(-3.0 * dye)) * edge; + float cover = (1.0 - exp(-4.0 * peak)) * edge; + vec3 pigment = (dye / max(peak, 1e-3)) * 0.35; + gl_FragColor = uInk > 0.5 ? vec4(pigment, cover) : vec4(smoke, 1.0); + } + `, + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + blending: THREE.AdditiveBlending, + toneMapped: false + }); + panel = new THREE.Mesh(new THREE.PlaneGeometry(PANEL_W, PANEL_H), material); + panel.name = 'fluid-panel'; + panel.renderOrder = 10; + panel.visible = false; + return panel; +} + +/** The scene look's enabled fluid entry, or null (XR reads the scene document directly). */ +function sceneFluidEntry() { + if (get(viewportOverrides).post === false) return null; + const doc = get(scenePost); + if (!doc?.enabled) return null; + return doc.effects.find((e) => e.kind === FLUID_KIND && e.enabled) ?? null; +} + +/** @param {any} renderer @param {any} p @param {number} now */ +function panelInput(renderer, p, now) { + for (const index of [0, 1]) { + let hit = null; + try { + hit = controllerRay(index).intersectObject(panel, false)[0] ?? null; + } catch {} + if (!hit?.uv) { + lastHit[index] = null; + continue; + } + const at = { x: hit.uv.x, y: hit.uv.y, t: now }; + const prev = lastHit[index]; + if (prev) { + const s = pointerSplat(prev, at, (now - prev.t) / 1000); + if (s && queue.length < 64) queue.push(s); + } + lastHit[index] = at; + } +} + +/** + * ONE per-frame entry (a single Scene useTask line). On a screen it only notices that the + * effect stopped compositing (and frees the targets later); in a headset it is the panel's + * presenter. @param {any} camera @param {any} renderer @param {number} now ms + */ +export function tickFluidLayer(camera, renderer = get(globalRenderer), now = performance.now()) { + const xr = !!renderer?.xr?.isPresenting; + if (!xr) { + if (panel?.visible) panel.visible = false; + panelPlaced = false; + if (presenter !== 'none' && now - lastUsed > IDLE_MS) { + presenter = 'none'; + publish(); + } + if (sim && now - lastUsed > FREE_MS) freeSim(); + return; + } + const entry = sceneFluidEntry(); + const tier = entry ? tierNow(true) : null; + currentTier = tier; + const root = backgroundRootGroup(); + if (!entry || !tier || !root) { + if (panel?.visible) panel.visible = false; + if (presenter === 'panel') { + presenter = 'none'; + publish(); + } + if (sim && now - lastUsed > FREE_MS) freeSim(); + return; + } + const p = normalizeFluidParams(entry.params); + ensurePanel(); + if (panel.parent !== root) root.add(panel); + if (!panelPlaced) { + // ahead of where the viewer looks when the session starts, upright, facing them + const xrCam = renderer.xr.getCamera(); + xrCam.getWorldPosition(headPos); + xrCam.getWorldDirection(headDir); + headDir.y = 0; + if (headDir.lengthSq() < 1e-6) headDir.set(0, 0, -1); + headDir.normalize(); + panel.position.copy(headPos).addScaledVector(headDir, PANEL_DIST); + panel.lookAt(headPos.x, panel.position.y, headPos.z); + panel.updateMatrixWorld(true); + panelPlaced = true; + } + panel.visible = true; + ensureSim(tier, PANEL_W / PANEL_H); + presenter = 'panel'; + panelInput(renderer, p, now); + stepShared(renderer, p, now); + const m = panel.material; + m.uniforms.uDye.value = sim?.texture ?? null; + m.uniforms.uIntensity.value = p.intensity; + const ink = p.style === 'ink'; + m.uniforms.uInk.value = ink ? 1 : 0; + const blending = ink ? THREE.NormalBlending : THREE.AdditiveBlending; + if (m.blending !== blending) { + m.blending = blending; + m.needsUpdate = true; + } +} + +// ---- test / debug seams ------------------------------------------------------------------ + +/** Queue a splat as if a pointer had moved (UV, UV/s). @param {number} x @param {number} y @param {number} vx @param {number} vy */ +export function fluidSplatForTest(x, y, vx, vy) { + queue.push({ x, y, vx, vy }); +} + +/** Hold the automatic stirrer, so a test frame differs only by its own splats. @param {boolean} on */ +export function holdFluidAutoForTest(on) { + autoHeld = !!on; +} + +/** a small 8-bit copy of the dye, for the read-back (half-float read-back is not portable) @type {any} */ +let probe = null; + +/** + * The dye's total (sum of r+g+b over a 64x36 8-bit copy, so dense patches clip at 1 per + * channel) — "is there smoke, and on which half". Null with no sim. + * @returns {{total: number, left: number, right: number}|null} + */ +export function readFluidDyeForTest() { + const renderer = get(globalRenderer); + if (!sim || !renderer) return null; + const w = 64; + const h = 36; + probe ??= new THREE.WebGLRenderTarget(w, h, { depthBuffer: false }); + const prevTarget = renderer.getRenderTarget(); + const xrOn = renderer.xr.enabled; + renderer.xr.enabled = false; + const buf = new Uint8Array(w * h * 4); + try { + const m = sim.mats.scale; + m.uniforms.uSource.value = sim.texture; + m.uniforms.uScale.value = 1; + sim.blit(renderer, m, probe); + renderer.readRenderTargetPixels(probe, 0, 0, w, h, buf); + } catch { + return null; + } finally { + renderer.setRenderTarget(prevTarget); + renderer.xr.enabled = xrOn; + } + let total = 0; + let left = 0; + let right = 0; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const i = (y * w + x) * 4; + const v = (buf[i] + buf[i + 1] + buf[i + 2]) / 255; + total += v; + if (x < w / 2) left += v; + else right += v; + } + return { total, left, right }; +} + +/** debug hook row */ +export function fluidLayerDebug() { + return { + ...get(fluidState), + queued: queue.length, + idleMs: lastUsed ? Math.round(performance.now() - lastUsed) : null, + panel: panel ? { visible: panel.visible, placed: panelPlaced, parent: panel.parent?.name ?? null } : null, + sim: sim ? { sim: [sim.velocity.w, sim.velocity.h], dye: [sim.dye.w, sim.dye.h] } : null + }; +} diff --git a/src/lib/fluidLayerCore.js b/src/lib/fluidLayerCore.js new file mode 100644 index 000000000..47d1faa60 --- /dev/null +++ b/src/lib/fluidLayerCore.js @@ -0,0 +1,138 @@ +// 65 M6 — the SMOKE / INK FLUID LAYER's rules, pure (imports NOTHING; vitest runs it with no +// GPU). The runtime (fluidLayer.js) owns the solver, the post effect and the XR panel; this is +// only what it decides: the look entry's params, the device-budget tier, the dye colour, the +// solver physics a set of params means, and where the automatic stirrer is at a moment. + +/** @typedef {{style: 'smoke'|'ink', palette: string, intensity: number, curl: number, fade: number, radius: number, auto: boolean}} FluidParams */ +/** @typedef {{tier: 'high'|'medium'|'low', simRes: number, dyeRes: number, iterations: number}} FluidTier */ + +export const FLUID_STYLES = ['smoke', 'ink']; +export const FLUID_PALETTES = ['rainbow', 'ember', 'ocean', 'mono']; + +/** @type {FluidParams} */ +export const FLUID_DEFAULTS = Object.freeze({ + style: 'smoke', + palette: 'rainbow', + intensity: 1, + curl: 25, + fade: 0.8, + radius: 0.25, + auto: true +}); + +/** @param {any} v @param {number} lo @param {number} hi @param {number} d */ +const clampNum = (v, lo, hi, d) => { + const n = Number(v); + return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; +}; + +/** + * ONE normalize for a look entry's params (they arrive through the wire, undo and the saves + * with the rest of the scene look). @param {any} raw @returns {FluidParams} + */ +export function normalizeFluidParams(raw) { + const d = FLUID_DEFAULTS; + return { + style: FLUID_STYLES.includes(raw?.style) ? raw.style : d.style, + palette: FLUID_PALETTES.includes(raw?.palette) ? raw.palette : d.palette, + intensity: clampNum(raw?.intensity, 0, 3, d.intensity), + curl: clampNum(raw?.curl, 0, 60, d.curl), + fade: clampNum(raw?.fade, 0, 5, d.fade), + radius: clampNum(raw?.radius, 0.05, 1, d.radius), + auto: raw?.auto === undefined ? d.auto : !!raw.auto + }; +} + +/** + * THE DEVICE BUDGET. The solver's cost is (sim texels x Jacobi iterations) + dye texels, so the + * tiers trade grid size and iterations: a desktop affords 128/512/20, a desktop the governor + * has already shrunk (dpr scale <= 0.72) 96/384/14, a phone or a headset 64/256/8. A headset + * whose governor is already capping particles gets NO fluid (null): it is the first thing to + * give up, before frames are. + * @param {{xr?: boolean, phone?: boolean, dprScale?: number, particlesCapped?: boolean}} ctx + * @returns {FluidTier|null} + */ +export function fluidTierFor(ctx) { + if (ctx.xr && ctx.particlesCapped) return null; + if (ctx.xr || ctx.phone) return { tier: 'low', simRes: 64, dyeRes: 256, iterations: 8 }; + if ((ctx.dprScale ?? 1) <= 0.72) return { tier: 'medium', simRes: 96, dyeRes: 384, iterations: 14 }; + return { tier: 'high', simRes: 128, dyeRes: 512, iterations: 20 }; +} + +/** + * What the params mean to the solver. `fade` is how fast the dye goes (0 = it never does); + * the velocity always fades a little, even at fade 0, or a stirred layer churns forever. + * @param {FluidParams} p @returns {import('./fluidSim.js').FluidPhysics} + */ +export function physicsFor(p) { + return { + curl: p.curl, + dyeFade: p.fade, + velocityFade: 0.2 + p.fade * 0.5, + pressureDecay: 0.8, + radius: p.radius + }; +} + +/** @param {number} h 0..1 @param {number} s @param {number} v @returns {number[]} */ +function hsv(h, s, v) { + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + switch (((i % 6) + 6) % 6) { + case 0: return [v, t, p]; + case 1: return [q, v, p]; + case 2: return [p, v, t]; + case 3: return [p, q, v]; + case 4: return [t, p, v]; + default: return [v, p, q]; + } +} + +/** + * The dye a splat puts in at phase `t` (seconds; the colour drifts as time goes). Linear RGB, + * scaled so one splat is visible but a held pointer does not blow out at once. + * @param {string} palette @param {number} t @returns {number[]} + */ +export function dyeColor(palette, t) { + const k = 0.15; + const wave = (/** @type {number} */ period) => 0.5 + 0.5 * Math.sin((t / period) * Math.PI * 2); + let c; + if (palette === 'ember') c = hsv(0.02 + 0.1 * wave(7), 0.9, 1); + else if (palette === 'ocean') c = hsv(0.5 + 0.17 * wave(9), 0.8, 1); + else if (palette === 'mono') c = [1, 1, 1]; + else c = hsv((t / 12) % 1, 0.85, 1); + return [c[0] * k, c[1] * k, c[2] * k]; +} + +/** + * The automatic stirrer: a slow Lissajous path (0..1 UV) and its velocity in UV per second, + * so a layer with nobody's pointer on it still moves. + * @param {number} t seconds @returns {{x: number, y: number, vx: number, vy: number}} + */ +export function autoStir(t) { + const a = 0.31; + const b = 0.23; + return { + x: 0.5 + 0.32 * Math.sin(t * a * Math.PI * 2), + y: 0.5 + 0.28 * Math.sin(t * b * Math.PI * 2 + 1.1), + vx: 0.32 * a * Math.PI * 2 * Math.cos(t * a * Math.PI * 2), + vy: 0.28 * b * Math.PI * 2 * Math.cos(t * b * Math.PI * 2 + 1.1) + }; +} + +/** + * A pointer move turned into a splat: position in UV (y up) and velocity in UV per second, + * clamped so a frame hitch is not a cannon. Null when it did not move. + * @param {{x: number, y: number}} from @param {{x: number, y: number}} to @param {number} dt seconds + */ +export function pointerSplat(from, to, dt) { + const dx = to.x - from.x; + const dy = to.y - from.y; + if (Math.abs(dx) + Math.abs(dy) < 1e-5) return null; + const s = 1 / Math.max(dt, 1 / 240); + const cap = 6; + return { x: to.x, y: to.y, vx: Math.max(-cap, Math.min(cap, dx * s)), vy: Math.max(-cap, Math.min(cap, dy * s)) }; +} diff --git a/src/lib/fluidSim.js b/src/lib/fluidSim.js index d9ccc7f24..e1167f991 100644 --- a/src/lib/fluidSim.js +++ b/src/lib/fluidSim.js @@ -227,6 +227,15 @@ export class FluidSim { this.type = THREE.HalfFloatType; this.steps = 0; this.splats = 0; + // set by allocate(); declared here so the checker knows they always exist after it + /** @type {Pair} */ this.velocity = /** @type {any} */ (null); + /** @type {Pair} */ this.pressure = /** @type {any} */ (null); + /** @type {Pair} */ this.dye = /** @type {any} */ (null); + /** @type {any} */ this.divergence = null; + /** @type {any} */ this.curl = null; + this.simTexel = new THREE.Vector2(); + this.dyeTexel = new THREE.Vector2(); + this.cleared = false; this.allocate(); } diff --git a/src/lib/particleRuntime.js b/src/lib/particleRuntime.js index fcced70d9..4e68855e3 100644 --- a/src/lib/particleRuntime.js +++ b/src/lib/particleRuntime.js @@ -590,6 +590,8 @@ export function particleEntries() { vel: [...entry.vel], inherit: entry.material.uniforms.uInherit.value, trail: entry.material.uniforms.uTrail.value, + flow: entry.material.uniforms.uFlow?.value ?? 0, // 65 M6 + flowSteps: entry.material.uniforms.uFlowSteps?.value ?? 0, space: entry.space, sprite: entry.sprite, burstT: entry.burstT, diff --git a/tests/e2e/fluid-layer.test.cjs b/tests/e2e/fluid-layer.test.cjs new file mode 100644 index 000000000..1a54bd9ed --- /dev/null +++ b/tests/e2e/fluid-layer.test.cjs @@ -0,0 +1,174 @@ +// 65 M6 — THE SMOKE / INK FLUID LAYER (a scene-look entry) and PARTICLE FLOW FIELDS, on a screen. +// 1. adding the look entry: the shared sim starts, the effect composites it, steps advance +// 2. the POINTER stirs it: with the automatic stirrer held, mouse strokes over the LEFT half put +// dye on the left (read back from the sim) and change the frame; a still pointer adds none +// 3. ink composites differently from smoke (same dye, a different frame) +// 4. the BUDGET: governor level 3 (resolution 72 %) drops the grid to the medium tier; level 6 +// (scene look off) stops compositing, so the sim stops stepping +// 5. switching the entry off stops it too (nothing composites = nothing steps) +// 6. particle FLOW FIELDS: the Flow field preset drives uFlow; the field moves the particles +// (frames differ from the same preset with flow 0); the governor's particle cap halves the +// integration steps +// Run: APP_URL=https://localhost:5461/ node tests/e2e/fluid-layer.test.cjs +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const EVIDENCE = process.env.EVIDENCE_DIR || path.join(process.env.HOME, '.code/lanes-30/after-65/65-visuals'); + +const fl = (page) => page.evaluate(() => window.__stores.fluidLayer.fluidLayerDebug()); +const dye = (page) => page.evaluate(() => window.__stores.fluidLayer.readFluidDyeForTest()); + +async function canvasRect(page) { + return page.evaluate(() => { + let renderer; + window.__stores.globalRenderer.subscribe((v) => (renderer = v))(); + const b = renderer.domElement.getBoundingClientRect(); + return { x: b.x, y: b.y, w: b.width, h: b.height }; + }); +} + +/** a stroke of real mouse moves across part of the canvas (fractions of its size) */ +async function stroke(page, r, x0, y0, x1, y1, steps = 24) { + await page.mouse.move(r.x + r.w * x0, r.y + r.h * y0); + for (let i = 1; i <= steps; i++) { + const t = i / steps; + await page.mouse.move(r.x + r.w * (x0 + (x1 - x0) * t), r.y + r.h * (y0 + (y1 - y0) * t)); + await page.waitForTimeout(16); + } +} + +h.run(async () => { + fs.mkdirSync(EVIDENCE, { recursive: true }); + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 800 } } }); + const page = A.page; + await h.answerRestore(A).catch(() => {}); + await page.evaluate(() => { + window.__stores.objectActions?.deselectObject?.(); + window.__stores.qualityGovernor.setAutoQuality(false); + window.__stores.qualityGovernor.governorForTest.reset(); + }); + const r = await canvasRect(page); + const band = { x: Math.round(r.x + r.w * 0.1), y: Math.round(r.y + r.h * 0.2), width: Math.round(r.w * 0.8), height: Math.round(r.h * 0.55) }; + + console.log('\n=== 1. the look entry ==='); + let d = await fl(page); + h.check(d.presenter === 'none' && !d.sim, '1.1 inert with no entry: ' + JSON.stringify({ presenter: d.presenter, sim: d.sim })); + const before = await h.grabFrame(A, band); + const id = await page.evaluate(() => { + const sp = window.__stores.scenePost; + sp.setScenePostEnabled(true); + return sp.addPostEffect('fluid'); + }); + await h.eventually(() => fl(page), (s) => s.presenter === 'post' && s.steps > 10, 'fluid stepping', 10000).catch(() => {}); + d = await fl(page); + h.check(d.presenter === 'post' && d.steps > 10, '1.2 the entry composites the shared sim and it steps: ' + JSON.stringify({ presenter: d.presenter, steps: d.steps })); + h.check(d.tier === 'high' && d.simRes === 128 && d.dyeRes === 512 && d.iterations === 20, '1.3 a desktop runs the full grid (128 / 512 / 20): ' + JSON.stringify({ tier: d.tier, simRes: d.simRes, dyeRes: d.dyeRes, it: d.iterations })); + h.check(d.sim && d.sim.sim[0] > d.sim.sim[1], '1.4 the grid follows the canvas aspect: ' + JSON.stringify(d.sim)); + await page.waitForTimeout(1500); + const auto = await h.grabFrame(A, band); + fs.writeFileSync(path.join(EVIDENCE, 'fluid-01-auto-stir.png'), auto); + h.check((await h.frameDelta(page, before, auto)).fraction > 0.01, '1.5 the automatic stirrer alone puts smoke on screen'); + + console.log('\n=== 2. the pointer stirs it ==='); + await page.evaluate((eid) => { + const s = window.__stores; + s.fluidLayer.holdFluidAutoForTest(true); + s.scenePost.setPostEffectParams(eid, { fade: 3 }); + }, id); + // let the auto smoke thin out + await page.waitForTimeout(2500); + await page.evaluate((eid) => window.__stores.scenePost.setPostEffectParams(eid, { fade: 0.3 }), id); + await page.waitForTimeout(400); + const still = await dye(page); + const quiet = await h.grabFrame(A, band); + await page.waitForTimeout(300); + const quiet2 = await dye(page); + h.check(quiet2 && quiet2.total <= still.total + 1, '2.1 a still pointer adds no dye (' + still.total.toFixed(1) + ' -> ' + quiet2.total.toFixed(1) + ')'); + const splatsBefore = (await fl(page)).splats; + for (let i = 0; i < 3; i++) await stroke(page, r, 0.1, 0.3 + i * 0.15, 0.4, 0.35 + i * 0.15); + await page.waitForTimeout(150); + const stirred = await dye(page); + d = await fl(page); + h.check(d.splats > splatsBefore + 20, '2.2 mouse moves over the canvas became splats (' + (d.splats - splatsBefore) + ')'); + h.check(stirred.total > quiet2.total + 5, '2.3 ...which put dye in (' + quiet2.total.toFixed(1) + ' -> ' + stirred.total.toFixed(1) + ')'); + h.check(stirred.left > stirred.right * 2, '2.4 ...on the LEFT, where the pointer went (left ' + stirred.left.toFixed(1) + ' / right ' + stirred.right.toFixed(1) + ')'); + const smoke = await h.grabFrame(A, band); + fs.writeFileSync(path.join(EVIDENCE, 'fluid-02-pointer-smoke.png'), smoke); + h.check((await h.frameDelta(page, quiet, smoke)).fraction > 0.02, '2.5 ...and the frame shows it'); + + console.log('\n=== 3. ink ==='); + await page.evaluate((eid) => window.__stores.scenePost.setPostEffectParams(eid, { style: 'ink', fade: 0 }), id); + await h.eventually(() => fl(page), (s) => s.presenter === 'post', 'rebuilt', 5000).catch(() => {}); + await page.waitForTimeout(400); + const ink = await h.grabFrame(A, band); + fs.writeFileSync(path.join(EVIDENCE, 'fluid-03-ink.png'), ink); + h.check((await h.frameDelta(page, smoke, ink)).fraction > 0.02, '3.1 ink composites the same dye differently from smoke'); + d = await fl(page); + h.check(d.sim !== null, '3.2 ...on the SAME sim (a param edit rebuilds the effect, not the solver): ' + JSON.stringify(d.sim)); + + console.log('\n=== 4. the device budget ==='); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.setLevel(3, 1e9)); + await h.eventually(() => fl(page), (s) => s.tier === 'medium', 'medium tier', 5000).catch(() => {}); + d = await fl(page); + h.check(d.tier === 'medium' && d.simRes === 96 && d.iterations === 14, '4.1 resolution 72 % drops the grid to the medium tier (96 / 384 / 14): ' + JSON.stringify({ tier: d.tier, simRes: d.simRes, it: d.iterations })); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.setLevel(6, 1e9 + 1000)); + await page.waitForTimeout(1200); + const s1 = (await fl(page)).steps; + await page.waitForTimeout(500); + d = await fl(page); + h.check(d.steps === s1 && d.presenter === 'none', '4.2 with the scene look given up, nothing composites and nothing steps: ' + JSON.stringify({ presenter: d.presenter, steps: [s1, d.steps] })); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.reset()); + await h.eventually(() => fl(page), (s) => s.presenter === 'post', 'back', 5000).catch(() => {}); + d = await fl(page); + h.check(d.presenter === 'post' && d.tier === 'high', '4.3 full quality again: back on, full grid: ' + JSON.stringify({ presenter: d.presenter, tier: d.tier })); + + console.log('\n=== 5. switched off ==='); + await page.evaluate((eid) => window.__stores.scenePost.setPostEffectEnabled(eid, false), id); + await page.waitForTimeout(900); + const s2 = (await fl(page)).steps; + await page.waitForTimeout(400); + d = await fl(page); + h.check(d.presenter === 'none' && d.steps === s2, '5.1 the entry off: no step, no composite: ' + JSON.stringify({ presenter: d.presenter, steps: [s2, d.steps] })); + await page.evaluate((eid) => window.__stores.scenePost.removePostEffect(eid), id); + + console.log('\n=== 6. particle flow fields ==='); + const uuid = await page.evaluate(() => { + const s = window.__stores; + s.commandsHandler.sceneCommand('/create sphere'); + let group; + s.objectsGroup.subscribe((g) => (group = g))(); + const o = group.children[group.children.length - 1]; + delete o.userData.physics; + let cam; + s.globalCamera.subscribe((c) => (cam = c))(); + const fwd = cam.getWorldDirection(cam.position.clone()); + o.position.copy(cam.position).addScaledVector(fwd, 6); + o.scale.setScalar(0.3); + o.updateMatrixWorld(true); + s.objectActions.deselectObject(); + s.particleActions.addParticlesPreset(o.uuid, 'flowfield'); + return o.uuid; + }); + const entryOf = () => page.evaluate((u) => window.__stores.particleRuntime.particleEntries().find((e) => e.uuid === u) ?? null, uuid); + await h.eventually(entryOf, (e) => !!e, 'emitter', 6000).catch(() => {}); + let e = await entryOf(); + h.check(e && e.flow > 0 && e.flowSteps === 4, '6.1 the Flow field preset drives the field (uFlow ' + e?.flow + ', 4 steps): ' + JSON.stringify(e && { flow: e.flow, steps: e.flowSteps })); + await page.waitForTimeout(800); + const withFlow = await h.grabFrame(A, band); + fs.writeFileSync(path.join(EVIDENCE, 'fluid-06-flowfield.png'), withFlow); + await page.evaluate((u) => window.__stores.particleActions.updateObjectParticles(u, { flow: 0 }), uuid); + await h.eventually(entryOf, (x) => x && x.flow === 0, 'flow 0', 4000).catch(() => {}); + await page.waitForTimeout(800); + const noFlow = await h.grabFrame(A, band); + fs.writeFileSync(path.join(EVIDENCE, 'fluid-06-noflow.png'), noFlow); + h.check((await h.frameDelta(page, withFlow, noFlow)).fraction > 0.002, '6.2 the field moves the particles (a frame with flow differs from one without)'); + await page.evaluate((u) => window.__stores.particleActions.updateObjectParticles(u, { flow: 1 }), uuid); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.setLevel(8, 2e9)); + await h.eventually(entryOf, (x) => x && x.flowSteps === 2, 'capped', 5000).catch(() => {}); + e = await entryOf(); + h.check(e && e.flowSteps === 2, '6.3 the governor\'s particle cap halves the integration steps (' + e?.flowSteps + ')'); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.reset()); + await h.finish(browser); +}); diff --git a/tests/e2e/visuals-xr-iwer.test.cjs b/tests/e2e/visuals-xr-iwer.test.cjs new file mode 100644 index 000000000..53fee094a --- /dev/null +++ b/tests/e2e/visuals-xr-iwer.test.cjs @@ -0,0 +1,115 @@ +// 65 M2 + M6 — THE QUEST PROFILE: the shader background and the smoke layer in a headset, on IWER +// (48 G2: the app's own XR path, a Quest 3 emulated). +// 1. on the screen first: the background draws full screen at the high tier (octave cap 8), the +// smoke is a look entry composited by the post stack +// 2. in the headset: the background becomes the big SPHERE at the LOW tier (octave cap 2 — the +// budget fallback), the smoke becomes a world PANEL ahead of the viewer on the low grid +// (64 / 256 / 8), under background-root (never a scene object) +// 3. the right controller's ray moves the background's pointer (aim left vs right) +// 4. sweeping a controller ray across the panel stirs it (splats + dye) +// 5. the Quest budget: once the governor caps particles, the headset gives up the smoke entirely +// (panel hidden, nothing steps); the background stays (at low) +// 6. leaving the headset: the screen effect takes over again, the background back to full screen +// Run: APP_URL=https://localhost:5461/ node tests/e2e/visuals-xr-iwer.test.cjs +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const EVIDENCE = process.env.EVIDENCE_DIR || path.join(process.env.HOME, '.code/lanes-30/after-65/65-visuals'); +const bg = (page) => page.evaluate(() => window.__stores.backgroundLayer.backgroundLayerDebug()); +const fl = (page) => page.evaluate(() => window.__stores.fluidLayer.fluidLayerDebug()); + +h.run(async () => { + fs.mkdirSync(EVIDENCE, { recursive: true }); + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + await h.answerRestore(A).catch(() => {}); + + console.log('\n=== 1. on the screen ==='); + const applied = await page.evaluate(async () => { + const s = window.__stores; + s.qualityGovernor.setAutoQuality(false); + s.qualityGovernor.governorForTest.reset(); + const r = await s.backgroundPresets.applyBackgroundPreset('soft-gradient'); + s.scenePost.setScenePostEnabled(true); + s.scenePost.addPostEffect('fluid'); + return r; + }); + h.check(applied.ok, '1.0 the built-in background applies: ' + JSON.stringify(applied)); + await h.eventually(() => bg(page), (s) => s.draw === 'screen' && s.compiled, 'bg compiled', 8000).catch(() => {}); + let b = await bg(page); + h.check(b.draw === 'screen' && b.tier === 'high' && b.octaveCap === 8, '1.1 full screen at the high tier, octave cap 8: ' + JSON.stringify({ draw: b.draw, tier: b.tier, cap: b.octaveCap })); + await h.eventually(() => fl(page), (s) => s.presenter === 'post', 'fluid post', 8000).catch(() => {}); + let f = await fl(page); + h.check(f.presenter === 'post' && f.tier === 'high', '1.2 the smoke composites as a look entry at the high tier: ' + JSON.stringify({ presenter: f.presenter, tier: f.tier })); + + console.log('\n=== 2. in the headset ==='); + await iwer.enter(page); + await iwer.pose(page, 'head', [0, 1.6, 0], { yaw: 0 }); + await iwer.frames(page, 20); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.reset()); + await iwer.frames(page, 20); + b = await bg(page); + h.check(b.draw === 'sphere' && b.visible.sphere && !b.visible.screen, '2.1 the background becomes the sphere around you (never a head-locked quad): ' + JSON.stringify({ draw: b.draw, visible: b.visible })); + h.check(b.tier === 'low' && b.octaveCap === 2, '2.2 ...at the LOW tier, octave cap 2 (the Quest budget): ' + JSON.stringify({ tier: b.tier, cap: b.octaveCap })); + f = await fl(page); + h.check(f.presenter === 'panel' && f.panel?.visible && f.panel?.parent === 'background-root', '2.3 the smoke becomes a world panel under background-root: ' + JSON.stringify({ presenter: f.presenter, panel: f.panel })); + h.check(f.tier === 'low' && f.simRes === 64 && f.dyeRes === 256 && f.iterations === 8, '2.4 ...on the low grid (64 / 256 / 8): ' + JSON.stringify({ tier: f.tier, simRes: f.simRes, dyeRes: f.dyeRes, it: f.iterations })); + const s0 = f.steps; + await iwer.frames(page, 20); + f = await fl(page); + h.check(f.steps > s0 + 10, '2.5 ...stepped by the XR frame loop (' + s0 + ' -> ' + f.steps + ')'); + + console.log('\n=== 3. the controller moves the background pointer ==='); + const aim = async (yaw) => { + await iwer.pose(page, 'right', [0.2, 1.4, -0.3], { yaw, pitch: 0.05 }); + await iwer.pose(page, 'left', [-0.2, 1.4, -0.3], { yaw, pitch: 0.05 }); + await iwer.frames(page, 40); + return (await bg(page)).pointer; + }; + const pl = await aim(0.6); + const pr = await aim(-0.6); + h.check(Math.abs(pl.x - pr.x) > 0.05, '3.1 aiming left vs right moves the pointer on the sphere (x ' + pl.x.toFixed(3) + ' vs ' + pr.x.toFixed(3) + ')'); + + console.log('\n=== 4. a controller ray stirs the panel ==='); + await page.evaluate(() => window.__stores.fluidLayer.holdFluidAutoForTest(true)); + const splats0 = (await fl(page)).splats; + const dye0 = await page.evaluate(() => window.__stores.fluidLayer.readFluidDyeForTest()); + for (let i = 0; i <= 20; i++) { + const yaw = 0.35 - (0.7 * i) / 20; + await iwer.pose(page, 'right', [0.2, 1.4, -0.3], { yaw, pitch: 0.05 }); + await iwer.frames(page, 2); + } + f = await fl(page); + const dye1 = await page.evaluate(() => window.__stores.fluidLayer.readFluidDyeForTest()); + h.check(f.splats > splats0 + 10, '4.1 sweeping the ray across the panel splats (' + (f.splats - splats0) + ')'); + h.check(dye1 && dye0 && dye1.total > dye0.total + 2, '4.2 ...and puts dye in (' + dye0?.total.toFixed(1) + ' -> ' + dye1?.total.toFixed(1) + ')'); + await page.evaluate(() => window.__stores.fluidLayer.holdFluidAutoForTest(false)); + await page.screenshot({ path: path.join(EVIDENCE, 'xr-panel-mirror.png') }).catch(() => {}); + + console.log('\n=== 5. the Quest budget fallback ==='); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.setLevel(8, 1e9)); + await iwer.frames(page, 20); + const s1 = (await fl(page)).steps; + await iwer.frames(page, 20); + f = await fl(page); + h.check(f.presenter === 'none' && !f.panel?.visible && f.steps === s1, '5.1 particles capped in a headset: the smoke is given up (panel hidden, no steps): ' + JSON.stringify({ presenter: f.presenter, panel: f.panel, steps: [s1, f.steps] })); + b = await bg(page); + h.check(b.draw === 'sphere' && b.tier === 'low', '5.2 ...the background stays, at low: ' + JSON.stringify({ draw: b.draw, tier: b.tier })); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.reset()); + await iwer.frames(page, 20); + f = await fl(page); + h.check(f.presenter === 'panel' && f.panel?.visible, '5.3 full quality again: the panel is back: ' + f.presenter); + + console.log('\n=== 6. out of the headset ==='); + await iwer.exit(page); + await page.waitForTimeout(800); + b = await bg(page); + h.check(b.draw === 'screen' && b.tier === 'high' && b.octaveCap === 8, '6.1 the background is full screen again, high tier: ' + JSON.stringify({ draw: b.draw, tier: b.tier, cap: b.octaveCap })); + await h.eventually(() => fl(page), (s) => s.presenter === 'post', 'post again', 5000).catch(() => {}); + f = await fl(page); + h.check(f.presenter === 'post' && !f.panel?.visible, '6.2 the screen effect takes the smoke back, the panel hides: ' + JSON.stringify({ presenter: f.presenter, panel: f.panel })); + await h.finish(browser); +}); diff --git a/tests/unit/fluidLayer.test.js b/tests/unit/fluidLayer.test.js new file mode 100644 index 000000000..a149dad40 --- /dev/null +++ b/tests/unit/fluidLayer.test.js @@ -0,0 +1,116 @@ +// @ts-nocheck +// 65 M6 — the smoke / ink fluid layer's pure rules (fluidLayerCore): params, budget, colour, stirrer. +import { describe, it, expect } from 'vitest'; +import { + FLUID_DEFAULTS, + normalizeFluidParams, + fluidTierFor, + physicsFor, + dyeColor, + autoStir, + pointerSplat +} from '../../src/lib/fluidLayerCore.js'; + +describe('normalizeFluidParams', () => { + it('fills the defaults from nothing', () => { + expect(normalizeFluidParams(undefined)).toEqual({ ...FLUID_DEFAULTS }); + }); + it('clamps numbers and refuses unknown choices', () => { + const p = normalizeFluidParams({ style: 'lava', palette: 'nope', intensity: 99, curl: -5, fade: 'x', radius: 0, auto: 0 }); + expect(p.style).toBe('smoke'); + expect(p.palette).toBe('rainbow'); + expect(p.intensity).toBe(3); + expect(p.curl).toBe(0); + expect(p.fade).toBe(FLUID_DEFAULTS.fade); + expect(p.radius).toBe(0.05); + expect(p.auto).toBe(false); + }); + it('keeps valid values', () => { + const p = normalizeFluidParams({ style: 'ink', palette: 'ocean', intensity: 0.5, curl: 40, fade: 2, radius: 0.5, auto: true }); + expect(p).toEqual({ style: 'ink', palette: 'ocean', intensity: 0.5, curl: 40, fade: 2, radius: 0.5, auto: true }); + }); +}); + +describe('fluidTierFor (the device budget)', () => { + it('desktop gets the full grid', () => { + expect(fluidTierFor({})).toMatchObject({ tier: 'high', simRes: 128, dyeRes: 512, iterations: 20 }); + }); + it('a governor-shrunk desktop drops to medium', () => { + expect(fluidTierFor({ dprScale: 0.72 })).toMatchObject({ tier: 'medium', simRes: 96, iterations: 14 }); + expect(fluidTierFor({ dprScale: 0.8 }).tier).toBe('high'); + }); + it('a phone or a headset drops to low', () => { + expect(fluidTierFor({ phone: true })).toMatchObject({ tier: 'low', simRes: 64, dyeRes: 256, iterations: 8 }); + expect(fluidTierFor({ xr: true }).tier).toBe('low'); + }); + it('a headset already capping particles gets no fluid', () => { + expect(fluidTierFor({ xr: true, particlesCapped: true })).toBeNull(); + // a desktop capping particles still has it (the cap is about particle count) + expect(fluidTierFor({ particlesCapped: true })).not.toBeNull(); + }); + it('every tier costs less than the one above', () => { + const cost = (t) => t.simRes * t.simRes * t.iterations + t.dyeRes * t.dyeRes; + expect(cost(fluidTierFor({ dprScale: 0.5 }))).toBeLessThan(cost(fluidTierFor({}))); + expect(cost(fluidTierFor({ xr: true }))).toBeLessThan(cost(fluidTierFor({ dprScale: 0.5 }))); + }); +}); + +describe('physicsFor', () => { + it('the velocity always fades, even when the dye never does', () => { + const still = physicsFor(normalizeFluidParams({ fade: 0 })); + expect(still.dyeFade).toBe(0); + expect(still.velocityFade).toBeGreaterThan(0); + expect(physicsFor(normalizeFluidParams({ fade: 5 })).velocityFade).toBeGreaterThan(still.velocityFade); + }); +}); + +describe('dyeColor', () => { + it('is small positive linear RGB for every palette', () => { + for (const palette of ['rainbow', 'ember', 'ocean', 'mono']) + for (const t of [0, 3.3, 11]) { + const c = dyeColor(palette, t); + expect(c).toHaveLength(3); + for (const v of c) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(0.15 + 1e-9); + } + } + }); + it('rainbow drifts with time, mono does not', () => { + expect(dyeColor('rainbow', 0)).not.toEqual(dyeColor('rainbow', 4)); + expect(dyeColor('mono', 0)).toEqual(dyeColor('mono', 4)); + }); +}); + +describe('autoStir', () => { + it('stays inside the layer and its velocity matches its path', () => { + for (let t = 0; t < 30; t += 0.7) { + const s = autoStir(t); + expect(s.x).toBeGreaterThan(0.1); + expect(s.x).toBeLessThan(0.9); + expect(s.y).toBeGreaterThan(0.1); + expect(s.y).toBeLessThan(0.9); + const h = 1e-4; + const n = autoStir(t + h); + expect((n.x - s.x) / h).toBeCloseTo(s.vx, 2); + expect((n.y - s.y) / h).toBeCloseTo(s.vy, 2); + } + }); +}); + +describe('pointerSplat', () => { + it('is null for no movement', () => { + expect(pointerSplat({ x: 0.5, y: 0.5 }, { x: 0.5, y: 0.5 }, 0.016)).toBeNull(); + }); + it('turns a move into UV/s at the new position', () => { + const s = pointerSplat({ x: 0.4, y: 0.5 }, { x: 0.5, y: 0.5 }, 0.1); + expect(s.x).toBe(0.5); + expect(s.vx).toBeCloseTo(1, 6); + expect(s.vy).toBe(0); + }); + it('clamps a frame hitch', () => { + const s = pointerSplat({ x: 0, y: 0 }, { x: 1, y: 1 }, 0); + expect(s.vx).toBe(6); + expect(s.vy).toBe(6); + }); +}); From 49d1b86f56bc282f22caf2080fb3f91ba7f19843 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 17:37:14 +0300 Subject: [PATCH 6/8] 65 phase 6: visuals-perf (fps rows, desktop + Quest profile, uncapped frames) and visuals-two-peer (scene data shared, pointer/smoke local) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/visuals-perf.cjs | 161 ++++++++++++++++++++++++++++ tests/e2e/visuals-two-peer.test.cjs | 99 +++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 tests/e2e/visuals-perf.cjs create mode 100644 tests/e2e/visuals-two-peer.test.cjs diff --git a/tests/e2e/visuals-perf.cjs b/tests/e2e/visuals-perf.cjs new file mode 100644 index 000000000..165952879 --- /dev/null +++ b/tests/e2e/visuals-perf.cjs @@ -0,0 +1,161 @@ +// 65 M2 + M6 PERFORMANCE PROTOCOL — the fps rows for the shader backgrounds, the smoke layer and the particle +// flow field, on the desktop and on the Quest profile (IWER, a Quest 3 emulated: the app's own XR path, low +// tier). Run under `e2e-slot --exclusive` (frame times mean nothing with another browser on the GPU). +// +// Chromium runs with the frame-rate limit and vsync OFF, so a frame interval is what the frame COSTS on this +// GPU (a vsync-capped p50 reads 16.7 ms for everything — the 37-fx lesson). Rows: p50 / p95 frame time over +// 3 s after a 1 s warm-up, and in the headset renderer.info calls / triangles (XR renders directly, no +// composer, so the counts are the scene's own). The Quest budget the rows are read against: <= 150 calls, +// <= 300k triangles, <= 13.9 ms (72 Hz) — the frame time here is the DECK's GPU running the headset's +// workload at the low tier, a proxy, not a Quest measurement. +// APP_URL=https://localhost:5461/ node tests/e2e/visuals-perf.cjs (OUT= to save the rows) +const fs = require('fs'); +const h = require('./helpers.cjs'); +const iwer = require('./iwer.cjs'); + +const ARGS = [...h.GPU_ARGS, '--disable-frame-rate-limit', '--disable-gpu-vsync']; +const PRESETS = ['soft-gradient', 'mesh-gradient', 'silk-gradient', 'noise-flow', 'metaballs', 'grain-dither']; +const BACKGROUNDS_DIR = process.env.BACKGROUNDS_DIR || require('path').join(__dirname, '../../../packs-lane-65-visuals/backgrounds'); + +/** p50/p95 of frame intervals over `ms`, plus renderer.info (per frame) — in XR on the session's rAF */ +const measure = (page, ms = 3000) => + page.evaluate(async (ms) => { + let r; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + const session = r.xr.isPresenting ? r.xr.getSession() : null; + const raf = (cb) => (session ? session.requestAnimationFrame(cb) : requestAnimationFrame(cb)); + await new Promise((ok) => setTimeout(ok, 1000)); + const times = []; + let last = 0; + let calls = 0; + let tris = 0; + let n = 0; + await new Promise((done) => { + const end = performance.now() + ms; + const tick = (t) => { + if (last) times.push(t - last); + last = t; + calls += r.info.render.calls; + tris += r.info.render.triangles; + n++; + if (performance.now() < end) raf(tick); + else done(); + }; + raf(tick); + }); + times.sort((a, b) => a - b); + const q = (p) => +times[Math.min(times.length - 1, Math.floor(times.length * p))].toFixed(2); + return { frames: times.length, p50: q(0.5), p95: q(0.95), calls: Math.round(calls / n), tris: Math.round(tris / n) }; + }, ms); + +const setBackground = (page, key) => + page.evaluate(async (key) => { + const s = window.__stores; + if (!key) return s.backgroundLayer.removeBackground(); + return s.backgroundPresets.applyBackgroundPreset(key); + }, key); + +async function setup(browser, label, opts = {}) { + const A = await h.setupPage(browser, label, { context: { viewport: { width: 1280, height: 800 } }, ...opts }); + await h.answerRestore(A).catch(() => {}); + await A.page.route(/\/backgrounds\/[A-Za-z0-9._-]+\.json(\?.*)?$/, (route) => { + const name = new URL(route.request().url()).pathname.split('/').pop(); + const file = require('path').join(BACKGROUNDS_DIR, name); + if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: 'missing' }); + return route.fulfill({ status: 200, contentType: 'application/json', body: fs.readFileSync(file, 'utf8') }); + }); + await A.page.evaluate(() => { + const s = window.__stores; + s.objectActions?.deselectObject?.(); + s.qualityGovernor.setAutoQuality(false); + s.qualityGovernor.governorForTest.reset(); + }); + return A; +} + +h.run(async () => { + const rows = []; + const row = (profile, what, m) => { + rows.push({ profile, what, ...m }); + console.log(`${profile.padEnd(8)} ${what.padEnd(34)} p50 ${String(m.p50).padStart(6)} ms p95 ${String(m.p95).padStart(6)} ms calls ${String(m.calls).padStart(4)} tris ${String(m.tris).padStart(7)} (${m.frames} frames)`); + }; + const browser = await h.launch({ args: ARGS }); + + console.log('\n=== desktop (1280 x 800, high tier) ==='); + const D = await setup(browser, 'D'); + let page = D.page; + row('desktop', 'empty scene (baseline)', await measure(page)); + for (const key of PRESETS) { + await setBackground(page, key); + await h.eventually(() => page.evaluate(() => window.__stores.backgroundLayer.backgroundLayerDebug()), (s) => s.compiled && s.draw === 'screen', key, 8000).catch(() => {}); + row('desktop', 'background ' + key, await measure(page)); + } + await setBackground(page, null); + const fid = await page.evaluate(() => { + const sp = window.__stores.scenePost; + sp.setScenePostEnabled(true); + return sp.addPostEffect('fluid'); + }); + row('desktop', 'smoke layer (128/512/20)', await measure(page)); + await setBackground(page, 'metaballs'); + row('desktop', 'smoke layer + metaballs', await measure(page)); + await page.evaluate((id) => window.__stores.scenePost.removePostEffect(id), fid); + await setBackground(page, null); + await page.evaluate(() => { + const s = window.__stores; + let cam; + s.globalCamera.subscribe((c) => (cam = c))(); + const fwd = cam.getWorldDirection(cam.position.clone()); + const right = fwd.clone().cross(cam.up).normalize(); + window.__perfEmitters = []; + for (let i = 0; i < 8; i++) { + s.commandsHandler.sceneCommand('/create sphere'); + let group; + s.objectsGroup.subscribe((g) => (group = g))(); + const o = group.children[group.children.length - 1]; + delete o.userData.physics; + o.scale.setScalar(0.2); + o.position.copy(cam.position).addScaledVector(fwd, 6).addScaledVector(right, (i - 3.5) * 0.9); + o.updateMatrixWorld(true); + s.particleActions.addParticlesPreset(o.uuid, 'flowfield'); + window.__perfEmitters.push(o.uuid); + } + s.objectActions.deselectObject(); + }); + row('desktop', '8 Flow field emitters (trails)', await measure(page)); + await page.evaluate(() => window.__perfEmitters.forEach((u) => window.__stores.particleActions.updateObjectParticles(u, { flow: 0 }))); + row('desktop', '8 same emitters, flow 0', await measure(page)); + await D.ctx.close().catch(() => {}); + + console.log('\n=== Quest profile (IWER Quest 3, low tier) ==='); + const Q = await setup(browser, 'Q', { init: iwer.initScript(), context: { viewport: { width: 1280, height: 720 } } }); + page = Q.page; + await iwer.enter(page); + await iwer.pose(page, 'head', [0, 1.6, 0], { yaw: 0 }); + await iwer.pose(page, 'right', [0.2, 1.4, -0.3], { yaw: 0.1, pitch: 0.05 }); + await page.evaluate(() => window.__stores.qualityGovernor.governorForTest.reset()); + row('quest', 'empty scene (baseline)', await measure(page)); + for (const key of PRESETS) { + await setBackground(page, key); + await iwer.frames(page, 30); + const b = await page.evaluate(() => window.__stores.backgroundLayer.backgroundLayerDebug()); + row('quest', `background ${key} (${b.draw}, cap ${b.octaveCap})`, await measure(page)); + } + await setBackground(page, null); + await page.evaluate(() => { + const sp = window.__stores.scenePost; + sp.setScenePostEnabled(true); + sp.addPostEffect('fluid'); + }); + await iwer.frames(page, 30); + const f = await page.evaluate(() => window.__stores.fluidLayer.fluidLayerDebug()); + row('quest', `smoke panel (${f.simRes}/${f.dyeRes}/${f.iterations})`, await measure(page)); + await setBackground(page, 'metaballs'); + await iwer.frames(page, 30); + row('quest', 'smoke panel + metaballs sphere', await measure(page)); + + const budget = rows.filter((r) => r.profile === 'quest'); + for (const r of budget) h.check(r.calls <= 150 && r.tris <= 300000, `Quest budget calls/tris: ${r.what} (${r.calls} calls, ${r.tris} tris)`); + if (process.env.OUT) fs.writeFileSync(process.env.OUT, JSON.stringify(rows, null, 2)); + await h.finish(browser); +}); diff --git a/tests/e2e/visuals-two-peer.test.cjs b/tests/e2e/visuals-two-peer.test.cjs new file mode 100644 index 000000000..1c61f9d9e --- /dev/null +++ b/tests/e2e/visuals-two-peer.test.cjs @@ -0,0 +1,99 @@ +// 65 M2 + M6 — TWO PEERS: the background document and the smoke look entry are SCENE DATA (they ride the +// existing shader-graph and scene-look sync), while the pointer that moves a background and the smoke a +// pointer stirs are each viewer's own. +// 1. A applies a background preset: B receives the same document (preset, layer) and draws it +// 2. A switches it to Off and back: B follows; A removes it: B's goes too +// 3. a late JOINER (C dials after the background exists) receives it in the handshake +// 4. A adds the smoke entry: B composites it; A's pointer strokes add dye on A only +// 5. the pointer is local: A's pointer moves A's background pointer, not B's +// Run: APP_URL=https://localhost:5461/ node tests/e2e/visuals-two-peer.test.cjs +const h = require('./helpers.cjs'); + +const bg = (p) => p.page.evaluate(() => window.__stores.backgroundLayer.backgroundLayerDebug()); +const fl = (p) => p.page.evaluate(() => window.__stores.fluidLayer.fluidLayerDebug()); +const dye = (p) => p.page.evaluate(() => window.__stores.fluidLayer.readFluidDyeForTest()); + +async function rectOf(p) { + return p.page.evaluate(() => { + let r; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + const b = r.domElement.getBoundingClientRect(); + return { x: b.x, y: b.y, w: b.width, h: b.height }; + }); +} + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const opts = { context: { viewport: { width: 1100, height: 720 } } }; + const A = await h.setupPage(browser, 'A', opts); + const B = await h.setupPageQuiet(browser, 'B', [A], opts); + for (const p of [A, B]) { + await h.answerRestore(p).catch(() => {}); + await p.page.evaluate(() => window.__stores.qualityGovernor.setAutoQuality(false)); + } + await h.connect(B, A); + + console.log('\n=== 1. the background replicates ==='); + const r = await A.page.evaluate(() => window.__stores.backgroundPresets.applyBackgroundPreset('soft-gradient')); + h.check(r.ok, '1.0 A applies the built-in preset'); + await h.eventually(() => bg(B), (s) => s.preset === 'soft-gradient' && s.draw === 'screen', 'B has it', 15000).catch(() => {}); + let b = await bg(B); + h.check(b.preset === 'soft-gradient' && b.draw === 'screen' && b.compiled, '1.1 B receives the document and draws it: ' + JSON.stringify({ preset: b.preset, draw: b.draw, compiled: b.compiled })); + + console.log('\n=== 2. edits and removal follow ==='); + await A.page.evaluate(() => window.__stores.backgroundLayer.setBackgroundLayer({ mode: 'off' })); + await h.eventually(() => bg(B), (s) => s.layer?.mode === 'off', 'B off', 10000).catch(() => {}); + b = await bg(B); + h.check(b.layer?.mode === 'off' && b.draw === 'none', '2.1 Off on A: B keeps the document and stops drawing: ' + JSON.stringify({ mode: b.layer?.mode, draw: b.draw })); + await A.page.evaluate(() => window.__stores.backgroundLayer.setBackgroundLayer({ mode: 'screen' })); + await h.eventually(() => bg(B), (s) => s.draw === 'screen', 'B on', 10000).catch(() => {}); + h.check((await bg(B)).draw === 'screen', '2.2 back on: B draws again'); + + console.log('\n=== 3. a late joiner ==='); + const C = await h.setupPageQuiet(browser, 'C', [A, B], opts); + await h.answerRestore(C).catch(() => {}); + await h.connect(C, A); + await h.eventually(() => bg(C), (s) => s.preset === 'soft-gradient' && s.draw === 'screen', 'C has it', 20000).catch(() => {}); + const c = await bg(C); + h.check(c.preset === 'soft-gradient' && c.draw === 'screen', '3.1 C, dialing after the background exists, receives it: ' + JSON.stringify({ preset: c.preset, draw: c.draw })); + await C.ctx.close().catch(() => {}); + + console.log('\n=== 4. the smoke entry is shared, the smoke is not ==='); + await A.page.evaluate(() => { + const sp = window.__stores.scenePost; + sp.setScenePostEnabled(true); + const id = sp.addPostEffect('fluid'); + sp.setPostEffectParams(id, { auto: false, fade: 0.2 }); + }); + await h.eventually(() => fl(B), (s) => s.presenter === 'post', 'B smoke', 15000).catch(() => {}); + h.check((await fl(B)).presenter === 'post', '4.1 B receives the look entry and composites the smoke'); + await A.page.waitForTimeout(600); + const a0 = await dye(A); + const b0 = await dye(B); + const ra = await rectOf(A); + await A.page.mouse.move(ra.x + ra.w * 0.2, ra.y + ra.h * 0.5); + for (let i = 1; i <= 30; i++) { + await A.page.mouse.move(ra.x + ra.w * (0.2 + 0.6 * (i / 30)), ra.y + ra.h * (0.5 + 0.1 * Math.sin(i / 4))); + await A.page.waitForTimeout(16); + } + await A.page.waitForTimeout(200); + const a1 = await dye(A); + const b1 = await dye(B); + h.check(a1.total > a0.total + 5, '4.2 A\'s pointer stirs A\'s smoke (' + a0.total.toFixed(1) + ' -> ' + a1.total.toFixed(1) + ')'); + h.check(b1.total <= b0.total + 1, '4.3 ...and none of it reaches B (' + b0.total.toFixed(1) + ' -> ' + b1.total.toFixed(1) + ')'); + + console.log('\n=== 5. the background pointer is local ==='); + const pB0 = (await bg(B)).pointer; + await A.page.mouse.move(ra.x + ra.w * 0.9, ra.y + ra.h * 0.1); + await A.page.waitForTimeout(800); + const pA = (await bg(A)).pointer; + const pB1 = (await bg(B)).pointer; + h.check(pA.x > 0.7, '5.1 A\'s pointer moves A\'s background pointer (x ' + pA.x.toFixed(3) + ')'); + h.check(Math.abs(pB1.x - pB0.x) < 1e-6 && Math.abs(pB1.y - pB0.y) < 1e-6, '5.2 ...B\'s does not move (' + JSON.stringify([pB0.x, pB1.x]) + ')'); + + await A.page.evaluate(() => window.__stores.backgroundLayer.removeBackground()); + await h.eventually(() => bg(B), (s) => !s.preset && s.draw === 'none', 'B removed', 10000).catch(() => {}); + b = await bg(B); + h.check(b.preset === null && b.draw === 'none', '5.3 A removes the background: B\'s is gone too: ' + JSON.stringify({ preset: b.preset, draw: b.draw })); + await h.finish(browser); +}); From 018c687ae4e9843c661b2d082c8e07efb877435d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 18:39:40 +0300 Subject: [PATCH 7/8] 65 tests: two premise fixes from the first browser run - background-layer 2.10 measured the built-in Soft gradient, which is still by design (it moves only with the pointer); it now applies Noise flow first, with a usesClock premise (backgroundLayerDebug reports usesClock). - fluid-layer 2.4: left-to-right strokes throw their own smoke rightwards (measured left 93.9 / right 144.8, the screenshot shows it); the strokes are vertical on the left now. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/backgroundLayer.js | 1 + tests/e2e/background-layer.test.cjs | 9 ++++++++- tests/e2e/fluid-layer.test.cjs | 5 ++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/backgroundLayer.js b/src/lib/backgroundLayer.js index d2f48f3a8..ca26c8557 100644 --- a/src/lib/backgroundLayer.js +++ b/src/lib/backgroundLayer.js @@ -533,6 +533,7 @@ export function backgroundLayerDebug() { visible: Object.fromEntries(Object.entries(meshes).map(([k, m]) => [k, !!m?.visible])), programs: Object.fromEntries(Object.entries(materials).map(([k, m]) => [k, !!m])), usesPointer: !!compiled?.usesPointer, + usesClock: !!compiled?.usesClock, usesScroll: !!compiled?.usesScroll }; } diff --git a/tests/e2e/background-layer.test.cjs b/tests/e2e/background-layer.test.cjs index 6bf013fda..733706bdb 100644 --- a/tests/e2e/background-layer.test.cjs +++ b/tests/e2e/background-layer.test.cjs @@ -113,7 +113,14 @@ h.run(async () => { await page.evaluate(() => window.__stores.backgroundLayer.setBackgroundClockForTest(null)); } h.check(served >= 6, '2.9 the files came from the packs checkout (' + served + ' served)'); - // the clock runs again: the background drifts on its own (the shared clock) + // the clock runs again: the background drifts on its own (the shared clock). The built-in + // Soft gradient applied last is STILL by design (it moves only with the pointer), so a preset + // that reads the clock goes on first — and the premise says so. + await page.evaluate(() => window.__stores.backgroundPresets.applyBackgroundPreset('noise-flow')); + await h.eventually(() => bg(page), (s) => s.compiled && s.preset === 'noise-flow', 'noise-flow back', 8000).catch(() => {}); + const clocked = (await bg(page)).usesClock; + h.check(clocked === true, 'premise: Noise flow reads the shared clock (usesClock ' + clocked + ')'); + await page.waitForTimeout(400); const t0 = await h.grabFrame(A, clip); await page.waitForTimeout(1500); const t1 = await h.grabFrame(A, clip); diff --git a/tests/e2e/fluid-layer.test.cjs b/tests/e2e/fluid-layer.test.cjs index 1a54bd9ed..62edcd0d7 100644 --- a/tests/e2e/fluid-layer.test.cjs +++ b/tests/e2e/fluid-layer.test.cjs @@ -87,7 +87,10 @@ h.run(async () => { const quiet2 = await dye(page); h.check(quiet2 && quiet2.total <= still.total + 1, '2.1 a still pointer adds no dye (' + still.total.toFixed(1) + ' -> ' + quiet2.total.toFixed(1) + ')'); const splatsBefore = (await fl(page)).splats; - for (let i = 0; i < 3; i++) await stroke(page, r, 0.1, 0.3 + i * 0.15, 0.4, 0.35 + i * 0.15); + // VERTICAL strokes on the left: a stroke's own push moves the dye along it, so a sideways + // stroke throws its smoke towards the other half (measured: left-to-right strokes over + // 0.1..0.4 left more dye on the RIGHT) — up and down keeps it where the pointer went + for (let i = 0; i < 3; i++) await stroke(page, r, 0.12 + i * 0.1, 0.25, 0.12 + i * 0.1, 0.75); await page.waitForTimeout(150); const stirred = await dye(page); d = await fl(page); From eb70be8e435b7a5573be0ee3ea4da4fb4dff2b32 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 20:14:37 +0300 Subject: [PATCH 8/8] 65 tests: background-layer waits for the debounced compile (usesClock) and 4.1 expects the preset 2.10 leaves (58/58) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/background-layer.test.cjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/background-layer.test.cjs b/tests/e2e/background-layer.test.cjs index 733706bdb..997ece269 100644 --- a/tests/e2e/background-layer.test.cjs +++ b/tests/e2e/background-layer.test.cjs @@ -117,7 +117,8 @@ h.run(async () => { // Soft gradient applied last is STILL by design (it moves only with the pointer), so a preset // that reads the clock goes on first — and the premise says so. await page.evaluate(() => window.__stores.backgroundPresets.applyBackgroundPreset('noise-flow')); - await h.eventually(() => bg(page), (s) => s.compiled && s.preset === 'noise-flow', 'noise-flow back', 8000).catch(() => {}); + // the compile is debounced: `preset` changes at once, the IR (and usesClock) a moment later + await h.eventually(() => bg(page), (s) => s.compiled && s.preset === 'noise-flow' && s.usesClock, 'noise-flow compiled', 8000).catch(() => {}); const clocked = (await bg(page)).usesClock; h.check(clocked === true, 'premise: Noise flow reads the shared clock (usesClock ' + clocked + ')'); await page.waitForTimeout(400); @@ -169,7 +170,7 @@ h.run(async () => { const saved = s.shaderGraph.shaderGraphsSnapshot({ pruneMissing }); return { keys: Object.keys(saved), layer: saved.background?.layer, preset: saved.background?.preset }; }); - h.check(snap.keys.includes('background') && snap.preset === 'soft-gradient', '4.1 the background survives the SAVE snapshot (pruneMissing no longer drops scene documents): ' + JSON.stringify(snap)); + h.check(snap.keys.includes('background') && snap.preset === 'noise-flow', '4.1 the background survives the SAVE snapshot (pruneMissing no longer drops scene documents): ' + JSON.stringify(snap)); const sel = await page.evaluate((u) => Object.keys(window.__stores.sessions?.buildSelectionPayload?.('sel', [u])?.shaderGraphs ?? { na: 1 }), uuid); h.check(!sel.includes('background'), '4.2 ...and a SELECTION (a prefab) carries no background: ' + JSON.stringify(sel));