reze-engine 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/effect-schedule.d.ts +67 -0
- package/dist/effect-schedule.d.ts.map +1 -0
- package/dist/effect-schedule.js +96 -0
- package/dist/engine.d.ts +152 -21
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +399 -204
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/shaders/anchor-table.d.ts +1 -1
- package/dist/shaders/anchor-table.js +3 -3
- package/dist/shaders/cast-api.d.ts +1 -1
- package/dist/shaders/cast-api.js +2 -2
- package/dist/shaders/directives.d.ts +73 -0
- package/dist/shaders/directives.d.ts.map +1 -0
- package/dist/shaders/directives.js +238 -0
- package/dist/shaders/lights.d.ts +0 -10
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +11 -19
- package/dist/shaders/passes/composite.d.ts +5 -5
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +12 -5
- package/dist/shaders/passes/grid.d.ts +0 -2
- package/dist/shaders/passes/grid.d.ts.map +1 -1
- package/dist/shaders/passes/grid.js +0 -9
- package/dist/shaders/passes/particles.d.ts +0 -6
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +15 -19
- package/dist/shaders/passes/scene-contract.d.ts +1 -1
- package/dist/shaders/passes/trails.d.ts.map +1 -1
- package/dist/shaders/passes/trails.js +9 -4
- package/package.json +2 -2
- package/src/effect-schedule.ts +120 -0
- package/src/engine.ts +398 -173
- package/src/index.ts +17 -1
- package/src/shaders/anchor-table.ts +3 -3
- package/src/shaders/cast-api.ts +2 -2
- package/src/shaders/directives.ts +289 -0
- package/src/shaders/lights.ts +11 -19
- package/src/shaders/passes/composite.ts +13 -6
- package/src/shaders/passes/grid.ts +0 -9
- package/src/shaders/passes/particles.ts +15 -21
- package/src/shaders/passes/scene-contract.ts +1 -1
- package/src/shaders/passes/trails.ts +9 -4
package/dist/engine.js
CHANGED
|
@@ -19,13 +19,14 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
|
|
|
19
19
|
import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
|
|
20
20
|
import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug";
|
|
21
21
|
import { paramChanged, sampleParamTrack } from "./param-track";
|
|
22
|
+
import { effectState } from "./effect-schedule";
|
|
22
23
|
import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades";
|
|
23
24
|
import { REFLECTION_DEBUG_WGSL, buildMirrorCamera } from "./reflection";
|
|
24
25
|
import { packHalf } from "./hdr";
|
|
25
26
|
import { evalIrradianceSH, projectIrradianceSH } from "./ibl";
|
|
26
27
|
import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, packLyrics } from "./shaders/lyrics-api";
|
|
27
28
|
import { sceneTargets as sceneTargetsFor, sceneColorFormats, setMrtIds, mrtIdsEnabled, SCENE_ID_FORMAT, } from "./shaders/passes/scene-contract";
|
|
28
|
-
import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit,
|
|
29
|
+
import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit, } from "./shaders/lights";
|
|
29
30
|
import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground";
|
|
30
31
|
import { outlineShaderWgsl } from "./shaders/passes/outline";
|
|
31
32
|
import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass";
|
|
@@ -33,14 +34,15 @@ import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shader
|
|
|
33
34
|
import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
|
|
34
35
|
import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
|
|
35
36
|
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
|
|
36
|
-
import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader,
|
|
37
|
-
import { buildParticleComputeShader, buildParticleRenderShader,
|
|
38
|
-
import { SIM_FORMAT, GRID_MAX, buildSimShader,
|
|
37
|
+
import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
|
|
38
|
+
import { buildParticleComputeShader, buildParticleRenderShader, particleEntryPoints, PARTICLE_STRIDE, } from "./shaders/passes/particles";
|
|
39
|
+
import { SIM_FORMAT, GRID_MAX, buildSimShader, gridEntryPoint, } from "./shaders/passes/grid";
|
|
39
40
|
import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails";
|
|
40
41
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
|
|
41
42
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
|
|
42
43
|
import { compileGraph } from "./graph/compile";
|
|
43
44
|
import { DEFAULT_GRAPH } from "./graph/presets/default";
|
|
45
|
+
import { parseDirectives, stripDirectives } from "./shaders/directives";
|
|
44
46
|
import { UNLIT_GRAPH } from "./graph/presets/unlit";
|
|
45
47
|
import { FACE_GRAPH } from "./graph/presets/face";
|
|
46
48
|
import { HAIR_GRAPH } from "./graph/presets/hair";
|
|
@@ -597,13 +599,13 @@ const FIELD_LAYER_BLEND = {
|
|
|
597
599
|
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
598
600
|
};
|
|
599
601
|
/**
|
|
600
|
-
*
|
|
602
|
+
* `#layer additive` — for LIGHT rather than matter.
|
|
601
603
|
*
|
|
602
604
|
* Alpha-over is right for anything with mass: smoke, fog, a backdrop. It is
|
|
603
605
|
* wrong for a glow, and visibly so the moment two of them cross — the later
|
|
604
606
|
* bolt occludes the earlier one in proportion to its own brightness, when what
|
|
605
607
|
* light does is get brighter. Unity and Unreal both ship exactly this split,
|
|
606
|
-
* and the particle path here already has it as
|
|
608
|
+
* and the particle path here already has it as `#blend additive`.
|
|
607
609
|
*
|
|
608
610
|
* Colour still scales by the author's alpha, so alpha keeps meaning "how much
|
|
609
611
|
* of this is here" and an effect fades out the way it always did. What changes
|
|
@@ -829,8 +831,21 @@ export class Engine {
|
|
|
829
831
|
// 360 backdrop (equirectangular skybox, sampled by view ray in composite).
|
|
830
832
|
this.backdropEquirectTexture = null;
|
|
831
833
|
this.backdropEquirectView = null;
|
|
832
|
-
|
|
833
|
-
|
|
834
|
+
/**
|
|
835
|
+
* The HDRI WORLD — what lights the scene, and what you see when nothing else
|
|
836
|
+
* is behind it.
|
|
837
|
+
*
|
|
838
|
+
* Separate from the backdrop because they answer different questions. An
|
|
839
|
+
* HDRI is a measurement of light: it drives the ambient term through
|
|
840
|
+
* `worldSH` whether or not it is the thing on screen. A 360 picture is
|
|
841
|
+
* wallpaper: it is what you see and it lights nothing. They shared one slot
|
|
842
|
+
* and so were mutually exclusive, which made "light her with a studio HDRI
|
|
843
|
+
* and put a different sky behind her" impossible to say — the ordinary split
|
|
844
|
+
* every renderer draws between a world and a film backdrop.
|
|
845
|
+
*/
|
|
846
|
+
this.worldEquirectTexture = null;
|
|
847
|
+
this.worldEquirectView = null;
|
|
848
|
+
this.worldStrength = 1;
|
|
834
849
|
/** The installed HDRI's folded irradiance SH (27 floats), or null. */
|
|
835
850
|
this.worldSH = null;
|
|
836
851
|
// The scene's user WGSL effect (setEffect). ONE per scene, mounted under the
|
|
@@ -1358,13 +1373,17 @@ export class Engine {
|
|
|
1358
1373
|
// In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
|
|
1359
1374
|
// STRENGTH in u[8] — Blender's world-strength dial, default 1.
|
|
1360
1375
|
const bg = this.backgroundColor;
|
|
1361
|
-
|
|
1376
|
+
// THE BACKDROP WINS WHAT YOU SEE; the world lights regardless. With only a
|
|
1377
|
+
// world installed it is also the sky, which is what an HDRI alone has
|
|
1378
|
+
// always done.
|
|
1379
|
+
const showingWorld = this.backdropEquirectView === null && this.worldEquirectView !== null;
|
|
1380
|
+
u[8] = showingWorld ? this.worldStrength : (bg?.x ?? 0);
|
|
1362
1381
|
u[9] = bg?.y ?? 0;
|
|
1363
1382
|
u[10] = bg?.z ?? 0;
|
|
1364
1383
|
// Base-layer mode only. A user effect is a separate LAYER over whichever
|
|
1365
1384
|
// base is active, and needs no flag of its own: the composite pipeline is
|
|
1366
1385
|
// rebuilt per effect, so the compiled variant IS the flag.
|
|
1367
|
-
u[11] = this.backdropEquirectView ?
|
|
1386
|
+
u[11] = this.backdropEquirectView ? 2 : showingWorld ? 3 : bg ? 1 : 0;
|
|
1368
1387
|
// Which display transform forms the frame (see viewTransform in composite.ts).
|
|
1369
1388
|
u[25] = v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0;
|
|
1370
1389
|
u[26] = this.canvas.width;
|
|
@@ -1781,7 +1800,10 @@ export class Engine {
|
|
|
1781
1800
|
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
1782
1801
|
{ binding: 4, resource: this.maskResolveView },
|
|
1783
1802
|
{ binding: 5, resource: this.filmicLutView },
|
|
1784
|
-
|
|
1803
|
+
// Whichever equirect is SHOWING — the backdrop if there is one, the
|
|
1804
|
+
// world otherwise. The world's light does not come through here; it
|
|
1805
|
+
// rides worldSH into the material shells.
|
|
1806
|
+
{ binding: 6, resource: this.backdropEquirectView ?? this.worldEquirectView ?? this.fallbackEquirectView },
|
|
1785
1807
|
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
1786
1808
|
{ binding: 8, resource: this.depthReadView },
|
|
1787
1809
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
@@ -1955,52 +1977,72 @@ export class Engine {
|
|
|
1955
1977
|
* affects lighting, bloom, or tonemapping. Pass null to remove (the background
|
|
1956
1978
|
* color, or transparency, takes over again).
|
|
1957
1979
|
*/
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1980
|
+
/**
|
|
1981
|
+
* The HDRI world: what LIGHTS the scene.
|
|
1982
|
+
*
|
|
1983
|
+
* Its irradiance goes to the world seat as spherical harmonics, so it lights
|
|
1984
|
+
* whether or not it is the thing you see — and it IS the thing you see until
|
|
1985
|
+
* a backdrop is set, which is what an HDRI on its own has always done.
|
|
1986
|
+
*
|
|
1987
|
+
* `strength` is Blender's world-strength dial and is folded into the
|
|
1988
|
+
* coefficients, so what lights her is what you see.
|
|
1989
|
+
*/
|
|
1990
|
+
setWorldEquirect(source, options) {
|
|
1991
|
+
this.worldEquirectTexture?.destroy();
|
|
1992
|
+
this.worldEquirectTexture = null;
|
|
1993
|
+
this.worldEquirectView = null;
|
|
1994
|
+
this.worldStrength = Math.max(options?.strength ?? 1, 0);
|
|
1964
1995
|
const hadSH = this.worldSH !== null;
|
|
1965
1996
|
this.worldSH = null;
|
|
1966
|
-
if (source &&
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
//
|
|
1970
|
-
// rides the SAME slot as the LDR path — one backdrop, two ingestions —
|
|
1971
|
-
// and the composite treats it as light rather than wallpaper (mode 3).
|
|
1997
|
+
if (source && this.device) {
|
|
1998
|
+
// Scene-linear radiance in rgba16float. The composite treats it as light
|
|
1999
|
+
// rather than wallpaper (mode 3) — a sun in it rolls off like a sun,
|
|
2000
|
+
// through the same exposure and view transform as the scene.
|
|
1972
2001
|
const tex = this.device.createTexture({
|
|
1973
|
-
label: "
|
|
2002
|
+
label: "world equirect (HDR)",
|
|
1974
2003
|
size: [source.width, source.height],
|
|
1975
2004
|
format: "rgba16float",
|
|
1976
2005
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
1977
2006
|
});
|
|
1978
2007
|
this.device.queue.writeTexture({ texture: tex }, packHalf(source.data), { bytesPerRow: source.width * 8, rowsPerImage: source.height }, [source.width, source.height]);
|
|
1979
|
-
this.
|
|
1980
|
-
this.
|
|
1981
|
-
|
|
1982
|
-
//
|
|
1983
|
-
//
|
|
1984
|
-
// and hand it to the world seat. The sun keeps the toon ramp — this is
|
|
1985
|
-
// the ambient term, exactly where the flat world colour used to sit.
|
|
2008
|
+
this.worldEquirectTexture = tex;
|
|
2009
|
+
this.worldEquirectView = tex.createView();
|
|
2010
|
+
// The sky lights the scene, not only backs it. The sun keeps the toon
|
|
2011
|
+
// ramp — this is the ambient term, exactly where the flat world colour
|
|
2012
|
+
// used to sit.
|
|
1986
2013
|
this.worldSH = projectIrradianceSH({ ...source, data: source.data }, 4);
|
|
1987
|
-
if (this.
|
|
2014
|
+
if (this.worldStrength !== 1) {
|
|
1988
2015
|
for (let i = 0; i < this.worldSH.length; i++)
|
|
1989
|
-
this.worldSH[i] *= this.
|
|
2016
|
+
this.worldSH[i] *= this.worldStrength;
|
|
1990
2017
|
}
|
|
1991
|
-
this.writeWorld();
|
|
1992
|
-
this.rebuildCompositeBindGroup();
|
|
1993
|
-
if (this.compositeUniformBuffer)
|
|
1994
|
-
this.writeCompositeViewUniforms();
|
|
1995
|
-
return;
|
|
1996
2018
|
}
|
|
1997
|
-
if (hadSH)
|
|
2019
|
+
if (this.worldSH || hadSH)
|
|
1998
2020
|
this.writeWorld();
|
|
1999
|
-
|
|
2021
|
+
this.rebuildCompositeBindGroup();
|
|
2022
|
+
if (this.device && this.compositeUniformBuffer)
|
|
2023
|
+
this.writeCompositeViewUniforms();
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* The 360 backdrop: what you SEE behind the scene.
|
|
2027
|
+
*
|
|
2028
|
+
* Wallpaper, and only wallpaper — it lights nothing. An HDRI belongs in
|
|
2029
|
+
* setWorldEquirect, which is why this no longer takes one: the two shared a
|
|
2030
|
+
* slot and were therefore mutually exclusive, and a picture that silently
|
|
2031
|
+
* changed the lighting because of its file format was a surprise nobody
|
|
2032
|
+
* asked for.
|
|
2033
|
+
*
|
|
2034
|
+
* Set alongside a world and this is what shows while the world goes on
|
|
2035
|
+
* lighting. Cleared, the world's own sky comes back.
|
|
2036
|
+
*/
|
|
2037
|
+
setBackdropEquirect(source) {
|
|
2038
|
+
this.backdropEquirectTexture?.destroy();
|
|
2039
|
+
this.backdropEquirectTexture = null;
|
|
2040
|
+
this.backdropEquirectView = null;
|
|
2041
|
+
if (source && this.device) {
|
|
2000
2042
|
let width = Math.max(1, "naturalWidth" in source ? source.naturalWidth : source.width);
|
|
2001
2043
|
let height = Math.max(1, "naturalHeight" in source ? source.naturalHeight : source.height);
|
|
2002
2044
|
let upload = source;
|
|
2003
|
-
// Panoramas routinely exceed maxTextureDimension2D (e.g.
|
|
2045
|
+
// Panoramas routinely exceed maxTextureDimension2D (e.g. 10000x5000 vs the
|
|
2004
2046
|
// default 8192) — quietly downscale to fit rather than surfacing an error.
|
|
2005
2047
|
const limit = this.device.limits.maxTextureDimension2D;
|
|
2006
2048
|
if (width > limit || height > limit) {
|
|
@@ -2043,7 +2085,44 @@ export class Engine {
|
|
|
2043
2085
|
primitive: { topology: "triangle-list" },
|
|
2044
2086
|
});
|
|
2045
2087
|
}
|
|
2046
|
-
|
|
2088
|
+
/**
|
|
2089
|
+
* Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
|
|
2090
|
+
* composite pass. ONE effect per scene, and the code says where it mounts by
|
|
2091
|
+
* which of these it defines — either, or both in one file:
|
|
2092
|
+
*
|
|
2093
|
+
* fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
|
|
2094
|
+
* fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
|
|
2095
|
+
*
|
|
2096
|
+
* `background` is a LAYER between the base background and the scene,
|
|
2097
|
+
* over-composited onto whichever base is active (solid color, 360 equirect, or
|
|
2098
|
+
* transparency) — its alpha lets the base show through, so a starfield is
|
|
2099
|
+
* stars over the user's background color. `foreground` composites over the
|
|
2100
|
+
* finished frame instead, which is where rain, snow, petals and fog live, and
|
|
2101
|
+
* is handed `depth`: the camera-space distance in metres of whatever the scene
|
|
2102
|
+
* drew at that pixel (the far plane where it drew nothing). Compare a
|
|
2103
|
+
* particle's own distance against it and the model occludes it; fog just reads
|
|
2104
|
+
* it, since fog's alpha IS a function of distance.
|
|
2105
|
+
*
|
|
2106
|
+
* `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
|
|
2107
|
+
* what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
|
|
2108
|
+
* seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
|
|
2109
|
+
* + alpha; alpha is the only "how much does this replace" control there is.
|
|
2110
|
+
* Declared `params` arrive as `params.<name>` (number → f32, Vec3 → vec3f),
|
|
2111
|
+
* shared by both mounts, and are later tweaked without recompiling via
|
|
2112
|
+
* setEffectParam.
|
|
2113
|
+
*
|
|
2114
|
+
* Both mounts are display-space: neither affects lighting, bloom or
|
|
2115
|
+
* tonemapping, and both are captured by offline export. A foreground makes the
|
|
2116
|
+
* scene pass STORE its depth buffer (it otherwise discards it into tile
|
|
2117
|
+
* memory) for as long as one is installed.
|
|
2118
|
+
*
|
|
2119
|
+
* Compiles off the hot path (async pipelines): on failure the previous effect
|
|
2120
|
+
* is KEPT and diagnostics are returned with line numbers relative to the
|
|
2121
|
+
* user's WGSL. Pass null to remove the effect.
|
|
2122
|
+
*/
|
|
2123
|
+
async compileEffect(
|
|
2124
|
+
/** The author's file, directives included — parsed here and nowhere else. */
|
|
2125
|
+
authored, params,
|
|
2047
2126
|
/** This effect's own declarations, already parsed by the caller — which had
|
|
2048
2127
|
* to read them anyway to build the scene table. */
|
|
2049
2128
|
anchors,
|
|
@@ -2051,7 +2130,22 @@ export class Engine {
|
|
|
2051
2130
|
alias) {
|
|
2052
2131
|
const noMounts = { background: false, foreground: false };
|
|
2053
2132
|
if (!this.device)
|
|
2054
|
-
return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts };
|
|
2133
|
+
return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0 };
|
|
2134
|
+
// WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
|
|
2135
|
+
// rather than running a regex of its own — eight parsers over one file was
|
|
2136
|
+
// eight chances to disagree about what it said, and they did.
|
|
2137
|
+
//
|
|
2138
|
+
// An unrecognised or malformed directive is an ERROR. `#` is not WGSL
|
|
2139
|
+
// syntax, so a line starting with one is unambiguously ours and there is
|
|
2140
|
+
// nothing to be lenient about; the old spelling lived in comments, where a
|
|
2141
|
+
// typo was indistinguishable from prose and could only ever be warned about.
|
|
2142
|
+
const parsed = parseDirectives(authored);
|
|
2143
|
+
if (parsed.errors.length)
|
|
2144
|
+
return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0 };
|
|
2145
|
+
const d = parsed.directives;
|
|
2146
|
+
// The compiler sees the file with its directive lines BLANKED, so every
|
|
2147
|
+
// diagnostic below still names the line the author is looking at.
|
|
2148
|
+
const wgsl = stripDirectives(authored);
|
|
2055
2149
|
// ── Which mounts did the author ask for? A declaration, not a setting: the
|
|
2056
2150
|
// entry points present in the source are the ones compiled in. Matching the
|
|
2057
2151
|
// `fn` keyword is enough to be safe against a `foreground` LOCAL or a call
|
|
@@ -2067,14 +2161,10 @@ export class Engine {
|
|
|
2067
2161
|
const te = trailEntryPoints(wgsl);
|
|
2068
2162
|
const wantsTrails = te.width || te.shade;
|
|
2069
2163
|
if (wantsTrails && !(te.width && te.shade)) {
|
|
2070
|
-
return {
|
|
2071
|
-
ok: false,
|
|
2072
|
-
diagnostics: [
|
|
2164
|
+
return { ok: false, diagnostics: [
|
|
2073
2165
|
`a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
|
|
2074
2166
|
`fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
|
|
2075
|
-
],
|
|
2076
|
-
mounts: noMounts,
|
|
2077
|
-
};
|
|
2167
|
+
], mounts: noMounts, params: [], duration: 0 };
|
|
2078
2168
|
}
|
|
2079
2169
|
if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
|
|
2080
2170
|
const missing = [
|
|
@@ -2082,7 +2172,7 @@ export class Engine {
|
|
|
2082
2172
|
pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
|
|
2083
2173
|
pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
|
|
2084
2174
|
].filter(Boolean);
|
|
2085
|
-
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts };
|
|
2175
|
+
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0 };
|
|
2086
2176
|
}
|
|
2087
2177
|
// One file, one kind — for now.
|
|
2088
2178
|
//
|
|
@@ -2097,35 +2187,27 @@ export class Engine {
|
|
|
2097
2187
|
// says so plainly instead of failing with "unresolved type Particle" from a
|
|
2098
2188
|
// pass they did not know they were compiling into.
|
|
2099
2189
|
if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
|
|
2100
|
-
return {
|
|
2101
|
-
ok: false,
|
|
2102
|
-
diagnostics: [
|
|
2190
|
+
return { ok: false, diagnostics: [
|
|
2103
2191
|
"an effect declares field mounts (background/foreground) or particles, not both — " +
|
|
2104
2192
|
"split them into two effects",
|
|
2105
|
-
],
|
|
2106
|
-
mounts: noMounts,
|
|
2107
|
-
};
|
|
2193
|
+
], mounts: noMounts, params: [], duration: 0 };
|
|
2108
2194
|
}
|
|
2109
2195
|
// lightEmit counts as a mount on its own: a pure lighting rig draws nothing
|
|
2110
2196
|
// and is still an effect — it is how a scene gets stage lights without also
|
|
2111
2197
|
// getting geometry it did not ask for.
|
|
2112
2198
|
if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
|
|
2113
|
-
return {
|
|
2114
|
-
ok: false,
|
|
2115
|
-
diagnostics: [
|
|
2199
|
+
return { ok: false, diagnostics: [
|
|
2116
2200
|
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
|
|
2117
2201
|
"fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
|
|
2118
2202
|
"the particle trio (particleInit/particleStep/particleShade), " +
|
|
2119
2203
|
"the ribbon pair (trailWidth/trailShade), " +
|
|
2120
|
-
"or fn lightEmit(i: u32) -> RzLight with
|
|
2121
|
-
],
|
|
2122
|
-
mounts: noMounts,
|
|
2123
|
-
};
|
|
2204
|
+
"or fn lightEmit(i: u32) -> RzLight with #lights <n>",
|
|
2205
|
+
], mounts: noMounts, params: [], duration: 0 };
|
|
2124
2206
|
}
|
|
2125
2207
|
const mounts = { background: hasBackground, foreground: hasForeground };
|
|
2126
2208
|
// ── Directives only some mounts honour ──
|
|
2127
2209
|
//
|
|
2128
|
-
//
|
|
2210
|
+
// #bloom sets the aux mask, and only the particle and ribbon modules write
|
|
2129
2211
|
// that mask: they draw inside the scene pass, in HDR, while the bloom
|
|
2130
2212
|
// pyramid can still see them. A field effect composites in DISPLAY space
|
|
2131
2213
|
// after tone mapping, so there is nothing left to pick it up and the
|
|
@@ -2138,30 +2220,8 @@ export class Engine {
|
|
|
2138
2220
|
// pinning an effect that declares this has to keep installing; saying so is
|
|
2139
2221
|
// all that was ever missing.
|
|
2140
2222
|
const warnings = [];
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
// Every pragma is matched with `\s*$` after it, so a line that carries a
|
|
2144
|
-
// note as well — `// @fullres — glyph edges are sub-pixel detail` — matches
|
|
2145
|
-
// none of them and is read as an ordinary comment. Nothing failed, nothing
|
|
2146
|
-
// said anything, and the effect simply ran without the property it asked
|
|
2147
|
-
// for: three shipped effects were silently half-res and a fourth silently
|
|
2148
|
-
// stopped being additive, each one's first line explaining why it needed
|
|
2149
|
-
// the thing it was not getting.
|
|
2150
|
-
//
|
|
2151
|
-
// Every directive this engine knows, so an unrecognised one is named rather
|
|
2152
|
-
// than ignored. Cheap: it runs once per install, over a file a human wrote.
|
|
2153
|
-
for (const m of wgsl.matchAll(/^[ \t]*\/\/[ \t]*(@[a-zA-Z]+)(.*)$/gm)) {
|
|
2154
|
-
const [, tag, rest] = m;
|
|
2155
|
-
if (!Engine.KNOWN.has(tag)) {
|
|
2156
|
-
warnings.push(`${tag} is not a directive this engine knows — it will be ignored.`);
|
|
2157
|
-
}
|
|
2158
|
-
else if (rest.trim() && !/^\s*[\w.\-+]+(\s+[\w.\-+]+)*\s*$/.test(rest)) {
|
|
2159
|
-
warnings.push(`${tag} has a note on the same line, so it does not parse and is being IGNORED. ` +
|
|
2160
|
-
`A directive must be alone on its line — put the note on the next one.`);
|
|
2161
|
-
}
|
|
2162
|
-
}
|
|
2163
|
-
if (parseParticleBloom(wgsl) && !wantsParticles && !wantsTrails) {
|
|
2164
|
-
warnings.push("// @bloom does nothing here. A field effect (background/foreground) composites after tone " +
|
|
2223
|
+
if (d.bloom && !wantsParticles && !wantsTrails) {
|
|
2224
|
+
warnings.push("#bloom does nothing here. A field effect (background/foreground) composites after tone " +
|
|
2165
2225
|
"mapping, past the bloom pyramid — the directive applies to particles and ribbons, which draw " +
|
|
2166
2226
|
"in HDR inside the scene pass. Make the effect's own falloff brighter instead.");
|
|
2167
2227
|
}
|
|
@@ -2181,7 +2241,7 @@ export class Engine {
|
|
|
2181
2241
|
let cursor = 0;
|
|
2182
2242
|
for (const [name, value] of entries) {
|
|
2183
2243
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
2184
|
-
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts };
|
|
2244
|
+
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration };
|
|
2185
2245
|
}
|
|
2186
2246
|
const isVec = typeof value !== "number";
|
|
2187
2247
|
const align = isVec ? 16 : 4;
|
|
@@ -2210,7 +2270,7 @@ export class Engine {
|
|
|
2210
2270
|
// module (buildFieldShader), so a bad effect can no longer produce errors at
|
|
2211
2271
|
// line numbers in a shader the author never wrote — and installing one no
|
|
2212
2272
|
// longer recompiles the composite's tone-mapping half at all.
|
|
2213
|
-
const gridSize = gridEntryPoint(wgsl) ?
|
|
2273
|
+
const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0;
|
|
2214
2274
|
// `alias` goes in: a field effect reads bones through _rzSlot exactly as a
|
|
2215
2275
|
// particle one does, and it was the only module never handed the mapping.
|
|
2216
2276
|
const fieldEffect = hasBackground || hasForeground ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length } : null;
|
|
@@ -2219,10 +2279,10 @@ export class Engine {
|
|
|
2219
2279
|
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
|
|
2220
2280
|
const scopeErr = await this.device.popErrorScope();
|
|
2221
2281
|
if (scopeErr)
|
|
2222
|
-
return { ok: false, diagnostics: [scopeErr.message], mounts };
|
|
2282
|
+
return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration };
|
|
2223
2283
|
// Declared like every other mount property: by what the source says, not by
|
|
2224
2284
|
// a setting somewhere else that an author cannot see from the file.
|
|
2225
|
-
const layerBlend =
|
|
2285
|
+
const layerBlend = d.additiveLayer
|
|
2226
2286
|
? FIELD_LAYER_BLEND_ADDITIVE
|
|
2227
2287
|
: FIELD_LAYER_BLEND;
|
|
2228
2288
|
let fieldPipeline = null;
|
|
@@ -2239,7 +2299,7 @@ export class Engine {
|
|
|
2239
2299
|
if (diagnostics.length === 0 && fieldScopeErr)
|
|
2240
2300
|
diagnostics.push(fieldScopeErr.message);
|
|
2241
2301
|
if (diagnostics.length > 0)
|
|
2242
|
-
return { ok: false, diagnostics, mounts };
|
|
2302
|
+
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration };
|
|
2243
2303
|
try {
|
|
2244
2304
|
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
2245
2305
|
label: "field layer pipeline",
|
|
@@ -2266,7 +2326,7 @@ export class Engine {
|
|
|
2266
2326
|
});
|
|
2267
2327
|
}
|
|
2268
2328
|
catch (e) {
|
|
2269
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
|
|
2329
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration };
|
|
2270
2330
|
}
|
|
2271
2331
|
}
|
|
2272
2332
|
let identity;
|
|
@@ -2290,7 +2350,7 @@ export class Engine {
|
|
|
2290
2350
|
]);
|
|
2291
2351
|
}
|
|
2292
2352
|
catch (e) {
|
|
2293
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
|
|
2353
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration };
|
|
2294
2354
|
}
|
|
2295
2355
|
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
2296
2356
|
// the previously installed effect running, exactly as a bad composite does.
|
|
@@ -2305,18 +2365,18 @@ export class Engine {
|
|
|
2305
2365
|
grid?.textures[1].destroy();
|
|
2306
2366
|
grid?.uniform.destroy();
|
|
2307
2367
|
trails?.uniform.destroy();
|
|
2308
|
-
return { ok: false, diagnostics, mounts };
|
|
2368
|
+
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration };
|
|
2309
2369
|
};
|
|
2310
2370
|
let particles = null;
|
|
2311
2371
|
if (wantsParticles) {
|
|
2312
|
-
const built = await this.buildParticles(wgsl, anchors, alias);
|
|
2372
|
+
const built = await this.buildParticles(wgsl, d, anchors, alias);
|
|
2313
2373
|
if (!built.ok)
|
|
2314
2374
|
return abandon(built.diagnostics);
|
|
2315
2375
|
particles = built.state;
|
|
2316
2376
|
}
|
|
2317
2377
|
let grid = null;
|
|
2318
2378
|
if (gridEntryPoint(wgsl)) {
|
|
2319
|
-
const built = await this.buildSim(wgsl, anchors, alias);
|
|
2379
|
+
const built = await this.buildSim(wgsl, d, anchors, alias);
|
|
2320
2380
|
if (!built.ok)
|
|
2321
2381
|
return abandon(built.diagnostics);
|
|
2322
2382
|
grid = built.state;
|
|
@@ -2327,13 +2387,9 @@ export class Engine {
|
|
|
2327
2387
|
// bone recorded without one would read zeroes and paint a line to the origin.
|
|
2328
2388
|
const trailSlots = anchors.filter((a) => a.trail).length;
|
|
2329
2389
|
if (trailSlots === 0) {
|
|
2330
|
-
return {
|
|
2331
|
-
ok: false,
|
|
2332
|
-
diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
|
|
2333
|
-
mounts,
|
|
2334
|
-
};
|
|
2390
|
+
return { ok: false, diagnostics: ["a ribbon effect needs at least one #anchor <bone> trail"], mounts, params: d.params, duration: d.duration };
|
|
2335
2391
|
}
|
|
2336
|
-
const built = await this.buildTrails(wgsl, anchors, alias);
|
|
2392
|
+
const built = await this.buildTrails(wgsl, d, anchors, alias);
|
|
2337
2393
|
if (!built.ok)
|
|
2338
2394
|
return abandon(built.diagnostics);
|
|
2339
2395
|
trails = built.state;
|
|
@@ -2346,13 +2402,13 @@ export class Engine {
|
|
|
2346
2402
|
// count is a function nothing calls. Either alone is a silent blank, which
|
|
2347
2403
|
// is the worst way for an effect to fail.
|
|
2348
2404
|
let lights = null;
|
|
2349
|
-
const declaredLights =
|
|
2405
|
+
const declaredLights = Math.min(d.lights, MAX_LIGHTS);
|
|
2350
2406
|
const emits = hasLightEmit(wgsl);
|
|
2351
2407
|
if (declaredLights > 0 !== emits) {
|
|
2352
2408
|
return abandon([
|
|
2353
2409
|
emits
|
|
2354
|
-
? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with
|
|
2355
|
-
: "
|
|
2410
|
+
? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with #lights <n>"
|
|
2411
|
+
: "#lights <n> needs fn lightEmit(i: u32) -> RzLight to fill those slots",
|
|
2356
2412
|
]);
|
|
2357
2413
|
}
|
|
2358
2414
|
if (declaredLights > 0) {
|
|
@@ -2380,6 +2436,8 @@ export class Engine {
|
|
|
2380
2436
|
}
|
|
2381
2437
|
const instance = {
|
|
2382
2438
|
wgsl,
|
|
2439
|
+
paramDecls: d.params,
|
|
2440
|
+
duration: d.duration,
|
|
2383
2441
|
paramLayout: layout,
|
|
2384
2442
|
paramsBuffer,
|
|
2385
2443
|
paramsData,
|
|
@@ -2394,6 +2452,12 @@ export class Engine {
|
|
|
2394
2452
|
// The effect's own clock starts now. Per effect so that one installed
|
|
2395
2453
|
// later still gets a frame where rzGridFrame() is 0 and can seed.
|
|
2396
2454
|
epochScene: this.sceneClock,
|
|
2455
|
+
// Fully on, unscheduled. An effect that is installed is showing;
|
|
2456
|
+
// scheduling it is something a caller does afterwards, and an install
|
|
2457
|
+
// that silently began at zero would look like a compile that failed.
|
|
2458
|
+
influence: 1,
|
|
2459
|
+
window: null,
|
|
2460
|
+
weight: 1,
|
|
2397
2461
|
// Its OWN resolution, no longer the scene's: an effect that never asked
|
|
2398
2462
|
// for full res is not promoted because a neighbour did.
|
|
2399
2463
|
// FULL RESOLUTION UNLESS TOLD OTHERWISE.
|
|
@@ -2406,12 +2470,12 @@ export class Engine {
|
|
|
2406
2470
|
// wearing a different hat: the safe answer has to be the one you get
|
|
2407
2471
|
// for saying nothing.
|
|
2408
2472
|
//
|
|
2409
|
-
// The cost is real and is why the half layer stays:
|
|
2473
|
+
// The cost is real and is why the half layer stays: `#halfres` is worth
|
|
2410
2474
|
// about 3.7x on a full-screen effect (Footprints, measured, 1.2ms
|
|
2411
2475
|
// against 4.5ms). It is the right call for a soft additive glow, which
|
|
2412
2476
|
// upsamples invisibly — and it is now a claim an author makes about
|
|
2413
2477
|
// their own effect rather than a fate that befalls one.
|
|
2414
|
-
fieldLayer:
|
|
2478
|
+
fieldLayer: d.fieldLayer,
|
|
2415
2479
|
fieldPipeline,
|
|
2416
2480
|
fieldClock,
|
|
2417
2481
|
// Filled by rebuildFieldBindGroup below, which needs the instance to
|
|
@@ -2451,7 +2515,7 @@ export class Engine {
|
|
|
2451
2515
|
async setEffects(list) {
|
|
2452
2516
|
const noMounts = { background: false, foreground: false };
|
|
2453
2517
|
if (!this.device)
|
|
2454
|
-
return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts }];
|
|
2518
|
+
return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }];
|
|
2455
2519
|
const requested = list ?? [];
|
|
2456
2520
|
if (requested.length === 0) {
|
|
2457
2521
|
for (const e of this.effects) {
|
|
@@ -2475,7 +2539,12 @@ export class Engine {
|
|
|
2475
2539
|
}
|
|
2476
2540
|
// One table for the whole scene, built before anything compiles: an effect's
|
|
2477
2541
|
// alias is its row, and a bone two effects both name is allocated once.
|
|
2478
|
-
|
|
2542
|
+
// The table needs every effect's anchors before any of them compiles, so
|
|
2543
|
+
// this is the one place a source is read twice — compileEffect parses it
|
|
2544
|
+
// again for everything else. A malformed file yields no anchors here and
|
|
2545
|
+
// fails with its real diagnostics there, which is the right order: the
|
|
2546
|
+
// error names the line, not the table.
|
|
2547
|
+
const perEffectAnchors = requested.map((e) => parseDirectives(e.wgsl).directives.anchors.slice(0, MAX_EFFECT_ANCHORS));
|
|
2479
2548
|
const table = buildAnchorTable(perEffectAnchors, MAX_EFFECT_ANCHORS);
|
|
2480
2549
|
const results = [];
|
|
2481
2550
|
const instances = [];
|
|
@@ -2489,6 +2558,8 @@ export class Engine {
|
|
|
2489
2558
|
instances.push(built.instance);
|
|
2490
2559
|
results.push({
|
|
2491
2560
|
ok: true,
|
|
2561
|
+
params: built.instance.paramDecls,
|
|
2562
|
+
duration: built.instance.duration,
|
|
2492
2563
|
// Installed, and still with something to say — a directive that parsed
|
|
2493
2564
|
// but will never fire. Same channel as the dropped-anchor note below.
|
|
2494
2565
|
diagnostics: built.warnings,
|
|
@@ -2548,7 +2619,7 @@ export class Engine {
|
|
|
2548
2619
|
});
|
|
2549
2620
|
this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)");
|
|
2550
2621
|
this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)");
|
|
2551
|
-
// Nothing to promote any more:
|
|
2622
|
+
// Nothing to promote any more: `#fullres` is per effect, read into
|
|
2552
2623
|
// fieldLayer when the instance is built, and both target pairs exist for
|
|
2553
2624
|
// the life of the surface. What used to be a scene-wide decision made here
|
|
2554
2625
|
// is now each effect's own.
|
|
@@ -2565,20 +2636,25 @@ export class Engine {
|
|
|
2565
2636
|
const noMounts = { background: false, foreground: false };
|
|
2566
2637
|
if (wgsl === null) {
|
|
2567
2638
|
await this.setEffects(null);
|
|
2568
|
-
return { ok: true, diagnostics: [], mounts: noMounts };
|
|
2639
|
+
return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 };
|
|
2569
2640
|
}
|
|
2570
2641
|
const [result] = await this.setEffects([{ wgsl, params }]);
|
|
2571
|
-
return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts };
|
|
2572
|
-
}
|
|
2573
|
-
async buildParticles(
|
|
2642
|
+
return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0 };
|
|
2643
|
+
}
|
|
2644
|
+
async buildParticles(
|
|
2645
|
+
/** Already stripped of directives — see compileEffect. */
|
|
2646
|
+
wgsl,
|
|
2647
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
2648
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
2649
|
+
d, anchors,
|
|
2574
2650
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
2575
2651
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
2576
2652
|
* describes the effect that is still on screen. */
|
|
2577
2653
|
alias) {
|
|
2578
2654
|
// No pragma means "some": an author who wrote the trio clearly wants
|
|
2579
2655
|
// particles, and failing over a missing comment would be pedantry.
|
|
2580
|
-
const count =
|
|
2581
|
-
const src = { wgsl, count, blend:
|
|
2656
|
+
const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES);
|
|
2657
|
+
const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom };
|
|
2582
2658
|
// Sparks want to spawn where a trail is, so the particle stages see the same
|
|
2583
2659
|
// cast buffer the trail draw reads.
|
|
2584
2660
|
const cast = {
|
|
@@ -2616,10 +2692,13 @@ export class Engine {
|
|
|
2616
2692
|
});
|
|
2617
2693
|
const uniform = this.device.createBuffer({
|
|
2618
2694
|
label: "particle uniforms",
|
|
2619
|
-
|
|
2695
|
+
// Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
|
|
2696
|
+
// The first was exactly full, and weight has to live in the same buffer
|
|
2697
|
+
// as the clock or a frame could draw one without the other.
|
|
2698
|
+
size: 32,
|
|
2620
2699
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2621
2700
|
});
|
|
2622
|
-
const uniformBytes = new ArrayBuffer(
|
|
2701
|
+
const uniformBytes = new ArrayBuffer(32);
|
|
2623
2702
|
const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) };
|
|
2624
2703
|
// Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
|
|
2625
2704
|
// visible to the vertex stage at all (WebGPU forbids it — a vertex shader
|
|
@@ -2825,6 +2904,11 @@ export class Engine {
|
|
|
2825
2904
|
emitLights(encoder) {
|
|
2826
2905
|
for (const e of this.effects) {
|
|
2827
2906
|
const l = e.lights;
|
|
2907
|
+
// NOT skipped at weight 0, unlike every other mount. Each effect writes
|
|
2908
|
+
// its OWN slots in a shared buffer that is never cleared, so a skipped
|
|
2909
|
+
// dispatch leaves last frame's lights burning — the one place where not
|
|
2910
|
+
// running is the wrong answer. The shader zeroes them instead, and the
|
|
2911
|
+
// dispatch it costs is a single workgroup.
|
|
2828
2912
|
if (!l || l.data[2] === 0)
|
|
2829
2913
|
continue;
|
|
2830
2914
|
// The effect's OWN epoch — the same one its field, particle, ribbon and
|
|
@@ -2832,6 +2916,7 @@ export class Engine {
|
|
|
2832
2916
|
// clock that was shared from the first installed effect; that clock is
|
|
2833
2917
|
// per effect now, so every mount in one file agrees by construction.
|
|
2834
2918
|
l.data[0] = this.sceneClock - e.epochScene;
|
|
2919
|
+
l.data[3] = e.weight;
|
|
2835
2920
|
this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer);
|
|
2836
2921
|
const cp = encoder.beginComputePass({ label: "light emit" });
|
|
2837
2922
|
cp.setPipeline(l.pipeline);
|
|
@@ -2846,6 +2931,11 @@ export class Engine {
|
|
|
2846
2931
|
if (!p)
|
|
2847
2932
|
continue;
|
|
2848
2933
|
p.data[0] = this.sceneClock - e.epochScene;
|
|
2934
|
+
// The SIMULATION runs at every weight, 0 included — only the draw stops.
|
|
2935
|
+
// A scheduled effect that froze while faded out would resume from the
|
|
2936
|
+
// state it left rather than the one it would have reached, so fading one
|
|
2937
|
+
// back in would rewind it.
|
|
2938
|
+
p.data[4] = e.weight;
|
|
2849
2939
|
// Clamped: a backgrounded tab returns with a delta of whole seconds, and an
|
|
2850
2940
|
// unclamped step flings every particle out of the scene in one frame.
|
|
2851
2941
|
p.data[1] = Math.min(0.1, Math.max(0, deltaTime));
|
|
@@ -2863,7 +2953,7 @@ export class Engine {
|
|
|
2863
2953
|
renderParticles(pass, view) {
|
|
2864
2954
|
for (const e of this.effects) {
|
|
2865
2955
|
const p = e.particles;
|
|
2866
|
-
if (!p)
|
|
2956
|
+
if (!p || e.weight === 0)
|
|
2867
2957
|
continue;
|
|
2868
2958
|
pass.setPipeline(p.render);
|
|
2869
2959
|
pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind);
|
|
@@ -2877,7 +2967,12 @@ export class Engine {
|
|
|
2877
2967
|
* and several declared bones is still one draw and nothing is computed per
|
|
2878
2968
|
* frame on the CPU.
|
|
2879
2969
|
*/
|
|
2880
|
-
async buildTrails(
|
|
2970
|
+
async buildTrails(
|
|
2971
|
+
/** Already stripped of directives — see compileEffect. */
|
|
2972
|
+
wgsl,
|
|
2973
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
2974
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
2975
|
+
d, anchors,
|
|
2881
2976
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
2882
2977
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
2883
2978
|
* describes the effect that is still on screen. */
|
|
@@ -2891,7 +2986,7 @@ export class Engine {
|
|
|
2891
2986
|
// nothing before: ribbon i was read as anchor slot i.
|
|
2892
2987
|
const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0);
|
|
2893
2988
|
const slots = ribbonSlots.length;
|
|
2894
|
-
const src = { wgsl, slots, ribbonSlots, blend:
|
|
2989
|
+
const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom };
|
|
2895
2990
|
const code = buildTrailShader(src, {
|
|
2896
2991
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
2897
2992
|
samples: TRAIL_SAMPLES,
|
|
@@ -3028,7 +3123,7 @@ export class Engine {
|
|
|
3028
3123
|
* Takes the pass rather than opening one: that IS the change.
|
|
3029
3124
|
*/
|
|
3030
3125
|
drawTrails(pass, view) {
|
|
3031
|
-
const drawn = this.effects.filter((e) => e.trails);
|
|
3126
|
+
const drawn = this.effects.filter((e) => e.trails && e.weight > 0);
|
|
3032
3127
|
if (drawn.length === 0)
|
|
3033
3128
|
return;
|
|
3034
3129
|
for (const e of drawn) {
|
|
@@ -3052,6 +3147,7 @@ export class Engine {
|
|
|
3052
3147
|
if (view === "camera") {
|
|
3053
3148
|
t.data[0] = this.sceneClock - e.epochScene;
|
|
3054
3149
|
t.data[1] = live;
|
|
3150
|
+
t.data[2] = e.weight;
|
|
3055
3151
|
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer);
|
|
3056
3152
|
}
|
|
3057
3153
|
pass.setPipeline(t.pipeline);
|
|
@@ -3063,9 +3159,25 @@ export class Engine {
|
|
|
3063
3159
|
* upsample. Runs the whole quad — uniform control flow, so effects may use
|
|
3064
3160
|
* derivatives freely, which the old inline path had to forbid. */
|
|
3065
3161
|
renderFieldPass(encoder) {
|
|
3066
|
-
|
|
3067
|
-
|
|
3162
|
+
// TWO PREDICATES, deliberately, and they are not interchangeable.
|
|
3163
|
+
//
|
|
3164
|
+
// MOUNTED decides whether the pass runs, and it must agree exactly with
|
|
3165
|
+
// fieldPairUsed — that is what the composite's bind group was built against,
|
|
3166
|
+
// at install, and it is not rebuilt per frame. A pass skipped under a
|
|
3167
|
+
// binding that still points at its target leaves the last frame it drew
|
|
3168
|
+
// sitting there, so an effect faded to nothing would freeze on screen
|
|
3169
|
+
// instead of disappearing.
|
|
3170
|
+
//
|
|
3171
|
+
// DRAWN decides what is drawn into it, and this is where weight is worth
|
|
3172
|
+
// something: a field mount is a full-screen quad however little of the frame
|
|
3173
|
+
// it ends up touching, so an effect that is scheduled off would otherwise
|
|
3174
|
+
// shade every pixel to multiply it out to nothing. The pass still clears —
|
|
3175
|
+
// which is what makes the layer transparent rather than stale — and shades
|
|
3176
|
+
// nothing.
|
|
3177
|
+
const mounted = this.effects.filter((e) => e.fieldPipeline && e.fieldBindGroups);
|
|
3178
|
+
if (mounted.length === 0)
|
|
3068
3179
|
return;
|
|
3180
|
+
const drawn = mounted.filter((e) => e.weight > 0);
|
|
3069
3181
|
// Each effect's own clock, before the pass that reads it. Seconds since
|
|
3070
3182
|
// THIS effect was installed — so an effect added to a running scene starts
|
|
3071
3183
|
// at zero and can seed, rather than joining whatever the first one is up to.
|
|
@@ -3073,6 +3185,7 @@ export class Engine {
|
|
|
3073
3185
|
if (!e.fieldClock)
|
|
3074
3186
|
continue;
|
|
3075
3187
|
this.fieldClockScratch[0] = this.sceneClock - e.epochScene;
|
|
3188
|
+
this.fieldClockScratch[1] = e.weight;
|
|
3076
3189
|
this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer);
|
|
3077
3190
|
}
|
|
3078
3191
|
// ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
|
|
@@ -3087,7 +3200,7 @@ export class Engine {
|
|
|
3087
3200
|
// (fieldLayerView). Clearing and storing an empty full-res rgba16f pair is
|
|
3088
3201
|
// two 16MB writes a frame to produce the transparent black the fallback
|
|
3089
3202
|
// already is. Most scenes leave the full-res pair empty, since an effect only
|
|
3090
|
-
// lands there by declaring
|
|
3203
|
+
// lands there by declaring #fullres.
|
|
3091
3204
|
let stamped = false;
|
|
3092
3205
|
for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
|
|
3093
3206
|
const bg = this.fieldBgViews[i];
|
|
@@ -3101,7 +3214,7 @@ export class Engine {
|
|
|
3101
3214
|
{ view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
3102
3215
|
],
|
|
3103
3216
|
// One query pair is reserved for "field", and it goes to the first pair
|
|
3104
|
-
// that actually runs — full res when something declared
|
|
3217
|
+
// that actually runs — full res when something declared #fullres, half
|
|
3105
3218
|
// otherwise. Pinning it to i === 0 would have measured a pass that, now
|
|
3106
3219
|
// that empty pairs are skipped, usually does not happen.
|
|
3107
3220
|
timestampWrites: stamped ? undefined : this.stamps("field"),
|
|
@@ -3165,12 +3278,17 @@ export class Engine {
|
|
|
3165
3278
|
* against: rzGridFrame() is 0 on the first step and every value it reads is
|
|
3166
3279
|
* zero, so seeding is just "if frame is 0, return the initial state".
|
|
3167
3280
|
*/
|
|
3168
|
-
async buildSim(
|
|
3281
|
+
async buildSim(
|
|
3282
|
+
/** Already stripped of directives — see compileEffect. */
|
|
3283
|
+
wgsl,
|
|
3284
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
3285
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
3286
|
+
d, anchors,
|
|
3169
3287
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
3170
3288
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
3171
3289
|
* describes the effect that is still on screen. */
|
|
3172
3290
|
alias) {
|
|
3173
|
-
const size =
|
|
3291
|
+
const size = Math.min(d.grid || 256, GRID_MAX);
|
|
3174
3292
|
const cast = {
|
|
3175
3293
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
3176
3294
|
samples: TRAIL_SAMPLES,
|
|
@@ -3305,10 +3423,19 @@ export class Engine {
|
|
|
3305
3423
|
getEffectMounts() {
|
|
3306
3424
|
return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false };
|
|
3307
3425
|
}
|
|
3308
|
-
/**
|
|
3309
|
-
*
|
|
3310
|
-
|
|
3311
|
-
|
|
3426
|
+
/**
|
|
3427
|
+
* Set one parameter on one INSTANCE.
|
|
3428
|
+
*
|
|
3429
|
+
* By index, because the scene holds a list and the same effect may appear in
|
|
3430
|
+
* it twice with different values — which is the whole point of an instance
|
|
3431
|
+
* and was impossible while this addressed `this.effect`, a singular left over
|
|
3432
|
+
* from when a scene could wear exactly one.
|
|
3433
|
+
*
|
|
3434
|
+
* A write, not a recompile: parameters live in their own uniform buffer, so
|
|
3435
|
+
* dragging a slider costs a 16-byte upload rather than a shader build.
|
|
3436
|
+
*/
|
|
3437
|
+
setEffectParam(index, name, value) {
|
|
3438
|
+
const fx = this.effects[index];
|
|
3312
3439
|
if (!fx || !fx.paramsBuffer)
|
|
3313
3440
|
return;
|
|
3314
3441
|
const slot = fx.paramLayout.get(name);
|
|
@@ -3323,6 +3450,114 @@ export class Engine {
|
|
|
3323
3450
|
}
|
|
3324
3451
|
this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData);
|
|
3325
3452
|
}
|
|
3453
|
+
/**
|
|
3454
|
+
* How much of one instance is showing, 0..1.
|
|
3455
|
+
*
|
|
3456
|
+
* The third of the three things an instance has — parameters, weight, time —
|
|
3457
|
+
* and the one a scheduler drives. Weight is not a parameter: a parameter is
|
|
3458
|
+
* whatever the author decided to expose and means only what their source
|
|
3459
|
+
* makes it mean, while weight means the same thing for every effect ever
|
|
3460
|
+
* written, including one whose author never heard of it. That is why it is
|
|
3461
|
+
* applied by engine-generated code at each mount's output rather than handed
|
|
3462
|
+
* to the source as a uniform to respect.
|
|
3463
|
+
*
|
|
3464
|
+
* At 0 nothing is drawn: no field quad, no particle draw, no ribbon, no light
|
|
3465
|
+
* dispatch. A scheduled effect outside its window costs its simulation and
|
|
3466
|
+
* nothing else — and a particle effect keeps simulating on purpose, so that
|
|
3467
|
+
* fading one back in continues rather than rewinds.
|
|
3468
|
+
*
|
|
3469
|
+
* Instant, and free: a float in a uniform every mount already uploads once a
|
|
3470
|
+
* frame. Nothing recompiles, so this is safe to drive per frame from a
|
|
3471
|
+
* timeline.
|
|
3472
|
+
*/
|
|
3473
|
+
setEffectInfluence(index, influence) {
|
|
3474
|
+
const fx = this.effects[index];
|
|
3475
|
+
if (!fx)
|
|
3476
|
+
return;
|
|
3477
|
+
// Clamped rather than trusted: above 1 the field's own clamp would swallow
|
|
3478
|
+
// it while an additive particle would happily keep getting brighter, so the
|
|
3479
|
+
// same number would mean two things.
|
|
3480
|
+
fx.influence = Math.min(1, Math.max(0, influence));
|
|
3481
|
+
}
|
|
3482
|
+
getEffectInfluence(index) {
|
|
3483
|
+
return this.effects[index]?.influence ?? 0;
|
|
3484
|
+
}
|
|
3485
|
+
/**
|
|
3486
|
+
* Schedule one instance: when it is alive, and how it enters and leaves.
|
|
3487
|
+
*
|
|
3488
|
+
* Null is the unscheduled case — on for the whole scene, on the scene's own
|
|
3489
|
+
* clock — and is what an effect starts as.
|
|
3490
|
+
*
|
|
3491
|
+
* The engine evaluates this every frame rather than taking a weight from a
|
|
3492
|
+
* caller, because every loop that renders would otherwise have to remember to
|
|
3493
|
+
* drive it. The offline export loop already carries a scar about exactly that
|
|
3494
|
+
* shape of bug. Evaluating where the scene clock advances means playback and
|
|
3495
|
+
* export cannot disagree, and neither can forget.
|
|
3496
|
+
*
|
|
3497
|
+
* A caller that wants to drive an effect from something OTHER than the scene
|
|
3498
|
+
* clock — an animation's progress, a skill firing — leaves this null and
|
|
3499
|
+
* writes setEffectInfluence and setEffectTime itself, per frame. Both paths
|
|
3500
|
+
* exist on purpose; this one is what a timeline wants.
|
|
3501
|
+
*/
|
|
3502
|
+
setEffectSchedule(index, windows) {
|
|
3503
|
+
const fx = this.effects[index];
|
|
3504
|
+
if (!fx)
|
|
3505
|
+
return;
|
|
3506
|
+
fx.window = windows && windows.length ? windows : null;
|
|
3507
|
+
}
|
|
3508
|
+
getEffectSchedule(index) {
|
|
3509
|
+
return this.effects[index]?.window ?? null;
|
|
3510
|
+
}
|
|
3511
|
+
/**
|
|
3512
|
+
* Every scheduled effect, at the current scene clock.
|
|
3513
|
+
*
|
|
3514
|
+
* Called once a frame, BEFORE anything reads a weight or a clock. An effect
|
|
3515
|
+
* with no window keeps whatever a caller last set, which is what makes the
|
|
3516
|
+
* manual path above work — evaluating it would fight the caller for the field
|
|
3517
|
+
* every frame.
|
|
3518
|
+
*/
|
|
3519
|
+
evaluateEffectSchedules() {
|
|
3520
|
+
// Read ONCE: it walks the cast, and every effect wants the same answer.
|
|
3521
|
+
const transport = this.transportTime();
|
|
3522
|
+
for (const fx of this.effects) {
|
|
3523
|
+
if (!fx.window || fx.window.length === 0) {
|
|
3524
|
+
fx.weight = fx.influence;
|
|
3525
|
+
continue;
|
|
3526
|
+
}
|
|
3527
|
+
const at = effectState(fx.window, fx.influence, transport);
|
|
3528
|
+
fx.weight = at.weight;
|
|
3529
|
+
// Its own clock, expressed the way the mounts read it. Every mount
|
|
3530
|
+
// derives time from the epoch against sceneClock, so this one write moves
|
|
3531
|
+
// the field, the particles, the ribbons, lightEmit and the grid together
|
|
3532
|
+
// — and hands them the STRIP's local time while they keep running on the
|
|
3533
|
+
// smooth monotonic clock a particle integrator needs.
|
|
3534
|
+
fx.epochScene = this.sceneClock - at.time;
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
/**
|
|
3538
|
+
* Move one instance's own clock to a given second.
|
|
3539
|
+
*
|
|
3540
|
+
* Everything an effect can animate is derived from its epoch — the field
|
|
3541
|
+
* clock, the particle and ribbon clocks, lightEmit's time argument, the grid's
|
|
3542
|
+
* frame counter — so moving the epoch moves all of them together and there is
|
|
3543
|
+
* no mount that can be left reading last frame's time.
|
|
3544
|
+
*
|
|
3545
|
+
* This is what lets an effect be SCHEDULED rather than merely switched on: an
|
|
3546
|
+
* instance that enters at bar 33 is handed a time that starts at zero there,
|
|
3547
|
+
* so it plays its own opening instead of joining whatever the scene clock had
|
|
3548
|
+
* reached. Feeding it the transport's time instead gives the other reading —
|
|
3549
|
+
* an effect that runs in lockstep with the music — and both are one call.
|
|
3550
|
+
*/
|
|
3551
|
+
setEffectTime(index, time) {
|
|
3552
|
+
const fx = this.effects[index];
|
|
3553
|
+
if (!fx)
|
|
3554
|
+
return;
|
|
3555
|
+
fx.epochScene = this.sceneClock - time;
|
|
3556
|
+
}
|
|
3557
|
+
getEffectTime(index) {
|
|
3558
|
+
const fx = this.effects[index];
|
|
3559
|
+
return fx ? this.sceneClock - fx.epochScene : 0;
|
|
3560
|
+
}
|
|
3326
3561
|
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
3327
3562
|
/** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
|
|
3328
3563
|
* the scene pass only stores its depth buffer on frames the gather reads. */
|
|
@@ -4873,7 +5108,7 @@ export class Engine {
|
|
|
4873
5108
|
format: this.hdrFormat,
|
|
4874
5109
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
4875
5110
|
});
|
|
4876
|
-
// The field layer — half resolution by default, full for
|
|
5111
|
+
// The field layer — half resolution by default, full for #fullres effects.
|
|
4877
5112
|
this.fieldFullW = width;
|
|
4878
5113
|
this.fieldFullH = height;
|
|
4879
5114
|
this.createFieldTargets();
|
|
@@ -5708,10 +5943,24 @@ export class Engine {
|
|
|
5708
5943
|
this.cameraAnimation = null;
|
|
5709
5944
|
this.camera.setVmdDriven(false);
|
|
5710
5945
|
}
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5946
|
+
/**
|
|
5947
|
+
* THE TRANSPORT'S CLOCK — where the scene is in its own playback.
|
|
5948
|
+
*
|
|
5949
|
+
* The first model with an active clip (playing or scrubbed), so a static stage
|
|
5950
|
+
* never freezes it at frame 0. Falls back to the first model with a clip, then
|
|
5951
|
+
* to 0 for an empty scene.
|
|
5952
|
+
*
|
|
5953
|
+
* NOT `sceneClock`, and the difference is the whole reason this has a name.
|
|
5954
|
+
* `sceneClock` only ever accumulates delta — it is how long the engine has
|
|
5955
|
+
* been running, it does not move when you scrub, and it does not stop when you
|
|
5956
|
+
* pause. Anything that should line up with what the transport shows has to
|
|
5957
|
+
* read THIS. An effect scheduled to frame 100 against sceneClock fires once,
|
|
5958
|
+
* a hundred frames after the page loaded, and never again.
|
|
5959
|
+
*
|
|
5960
|
+
* Deterministic offline: the export loop advances model animation by an exact
|
|
5961
|
+
* per-frame delta, so this reproduces frame for frame.
|
|
5962
|
+
*/
|
|
5963
|
+
transportTime() {
|
|
5715
5964
|
let fallback = null;
|
|
5716
5965
|
for (const inst of this.modelInstances.values()) {
|
|
5717
5966
|
// Stages are skipped outright. Scenery carries no motion, and it is added
|
|
@@ -8812,7 +9061,7 @@ export class Engine {
|
|
|
8812
9061
|
}
|
|
8813
9062
|
// Drive the shot from the camera VMD (synced to the animated model's clock).
|
|
8814
9063
|
if (this.camera.vmdDriven && this.cameraAnimation) {
|
|
8815
|
-
const pose = this.cameraAnimation.sample(this.
|
|
9064
|
+
const pose = this.cameraAnimation.sample(this.transportTime());
|
|
8816
9065
|
if (pose)
|
|
8817
9066
|
this.camera.setVmdPose(pose);
|
|
8818
9067
|
}
|
|
@@ -8946,6 +9195,12 @@ export class Engine {
|
|
|
8946
9195
|
// uniforms this frame.
|
|
8947
9196
|
this.evaluateDissolveCycles();
|
|
8948
9197
|
this.evaluateParamTracks();
|
|
9198
|
+
// FIRST among the things that read an effect, because every one of them
|
|
9199
|
+
// reads what this writes: the sim's clock, the particle uniform's weight,
|
|
9200
|
+
// the light dispatch, the field draw. Evaluated here rather than by a
|
|
9201
|
+
// caller so that playback, the export loop and a warm-up pass cannot
|
|
9202
|
+
// disagree about when an effect is alive — none of them has to remember it.
|
|
9203
|
+
this.evaluateEffectSchedules();
|
|
8949
9204
|
this.stepSim(encoder, deltaTime);
|
|
8950
9205
|
this.stepParticles(encoder, deltaTime);
|
|
8951
9206
|
// Before the scene pass, which READS the slots this writes. Same buffer,
|
|
@@ -9008,7 +9263,7 @@ export class Engine {
|
|
|
9008
9263
|
this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView));
|
|
9009
9264
|
// Last in the pass: depth-tested against everything drawn above, so a
|
|
9010
9265
|
// particle behind the character is simply hidden, and still inside the HDR
|
|
9011
|
-
// target so an
|
|
9266
|
+
// target so an `#bloom` effect reaches the pyramid below.
|
|
9012
9267
|
this.renderParticles(pass, "camera");
|
|
9013
9268
|
// Ribbons, in the same pass and after the particles: both are additive
|
|
9014
9269
|
// light in HDR, and both reach the bloom pyramid because of it. This used
|
|
@@ -10450,14 +10705,14 @@ Engine.JIGGLE_DAMPING_SCALE = 0.5;
|
|
|
10450
10705
|
* cost a degenerate quad the rasteriser rejects, which is cheaper than the
|
|
10451
10706
|
* prefix sum and readback a compacted draw list would need every frame.
|
|
10452
10707
|
*/
|
|
10453
|
-
/** Ceiling for
|
|
10708
|
+
/** Ceiling for `#particles`. Past this an author is asking for a stall. */
|
|
10454
10709
|
Engine.MAX_PARTICLES = 65536;
|
|
10455
10710
|
/**
|
|
10456
10711
|
* The field layer: user background/foreground mounts, ONE TARGET PAIR PER
|
|
10457
10712
|
* RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
|
|
10458
10713
|
* composite reads them full-over-half.
|
|
10459
10714
|
*
|
|
10460
|
-
*
|
|
10715
|
+
* `#fullres` used to be a property of the shared targets: one effect
|
|
10461
10716
|
* declaring it promoted the pass for every effect installed, so a starfield
|
|
10462
10717
|
* that upsamples perfectly paid four times the pixels because a keyboard
|
|
10463
10718
|
* beside it needed crisp edges. Measured, that was the largest avoidable cost
|
|
@@ -10481,7 +10736,7 @@ Engine.FIELD_SCALES = [1, 2];
|
|
|
10481
10736
|
*
|
|
10482
10737
|
* `field` earns its place now that a scene runs SEVERAL field effects at
|
|
10483
10738
|
* once: it is one pass with N draws, its resolution is a property of the
|
|
10484
|
-
* shared targets rather than of any one effect — so a single
|
|
10739
|
+
* shared targets rather than of any one effect — so a single `#fullres`
|
|
10485
10740
|
* effect quadruples the pixel count for all of them — and it is the pass the
|
|
10486
10741
|
* field restructure moves. Restructuring it while it was the only untimed
|
|
10487
10742
|
* pass in the frame would have meant reasoning about the cost instead of
|
|
@@ -10526,66 +10781,6 @@ Engine.FILMIC_LUT_WIDTH = 256;
|
|
|
10526
10781
|
// (vsync-to-vsync), recomputed at STATS_REFRESH_MS so the readout doesn't flicker.
|
|
10527
10782
|
Engine.STATS_WINDOW = 120;
|
|
10528
10783
|
Engine.STATS_REFRESH_MS = 500;
|
|
10529
|
-
/**
|
|
10530
|
-
* Install the scene's WGSL effect (shadertoy-style), rendered per-pixel in the
|
|
10531
|
-
* composite pass. ONE effect per scene, and the code says where it mounts by
|
|
10532
|
-
* which of these it defines — either, or both in one file:
|
|
10533
|
-
*
|
|
10534
|
-
* fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f
|
|
10535
|
-
* fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f
|
|
10536
|
-
*
|
|
10537
|
-
* `background` is a LAYER between the base background and the scene,
|
|
10538
|
-
* over-composited onto whichever base is active (solid color, 360 equirect, or
|
|
10539
|
-
* transparency) — its alpha lets the base show through, so a starfield is
|
|
10540
|
-
* stars over the user's background color. `foreground` composites over the
|
|
10541
|
-
* finished frame instead, which is where rain, snow, petals and fog live, and
|
|
10542
|
-
* is handed `depth`: the camera-space distance in metres of whatever the scene
|
|
10543
|
-
* drew at that pixel (the far plane where it drew nothing). Compare a
|
|
10544
|
-
* particle's own distance against it and the model occludes it; fog just reads
|
|
10545
|
-
* it, since fog's alpha IS a function of distance.
|
|
10546
|
-
*
|
|
10547
|
-
* `ray` is the pixel's normalized world-space view direction (LH, +Z forward —
|
|
10548
|
-
* what the skybox samples by), `uv` is 0..1 bottom-left origin, `time` is
|
|
10549
|
-
* seconds since apply, and `bgResolution()` gives the canvas size. Return sRGB
|
|
10550
|
-
* + alpha; alpha is the only "how much does this replace" control there is.
|
|
10551
|
-
* Declared `params` arrive as `params.<name>` (number → f32, Vec3 → vec3f),
|
|
10552
|
-
* shared by both mounts, and are later tweaked without recompiling via
|
|
10553
|
-
* setEffectParam.
|
|
10554
|
-
*
|
|
10555
|
-
* Both mounts are display-space: neither affects lighting, bloom or
|
|
10556
|
-
* tonemapping, and both are captured by offline export. A foreground makes the
|
|
10557
|
-
* scene pass STORE its depth buffer (it otherwise discards it into tile
|
|
10558
|
-
* memory) for as long as one is installed.
|
|
10559
|
-
*
|
|
10560
|
-
* Compiles off the hot path (async pipelines): on failure the previous effect
|
|
10561
|
-
* is KEPT and diagnostics are returned with line numbers relative to the
|
|
10562
|
-
* user's WGSL. Pass null to remove the effect.
|
|
10563
|
-
*/
|
|
10564
|
-
/**
|
|
10565
|
-
* Every directive an effect may declare — used ONLY to tell an author that
|
|
10566
|
-
* the line they wrote is not doing what they think. See compileEffect.
|
|
10567
|
-
*
|
|
10568
|
-
* It has to be complete, including the ones this engine does not read itself:
|
|
10569
|
-
* `@dissolve` is parsed by the host, and warning about it would be worse than
|
|
10570
|
-
* the silence this replaced — a false alarm on a working line teaches authors
|
|
10571
|
-
* to ignore the channel.
|
|
10572
|
-
*/
|
|
10573
|
-
Engine.KNOWN = new Set([
|
|
10574
|
-
"@anchor",
|
|
10575
|
-
"@layer",
|
|
10576
|
-
"@blend",
|
|
10577
|
-
"@bloom",
|
|
10578
|
-
"@lights",
|
|
10579
|
-
"@grid",
|
|
10580
|
-
"@particles",
|
|
10581
|
-
"@halfres",
|
|
10582
|
-
// Accepted and inert: full resolution is the default now, and an effect
|
|
10583
|
-
// that still says so is right about what it wants. Warning about it would
|
|
10584
|
-
// be telling authors off for the thing that used to be necessary.
|
|
10585
|
-
"@fullres",
|
|
10586
|
-
// The HOST's, not this engine's — see the note above.
|
|
10587
|
-
"@dissolve",
|
|
10588
|
-
]);
|
|
10589
10784
|
// ── GPU frustum cull ────────────────────────────────────────────────────────
|
|
10590
10785
|
//
|
|
10591
10786
|
// Sizes, once, so the arithmetic below is readable: a DrawMeta is 32 bytes
|