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/src/engine.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
|
|
|
29
29
|
import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow"
|
|
30
30
|
import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug"
|
|
31
31
|
import { paramChanged, sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
|
|
32
|
+
import { effectState, type EffectWindow } from "./effect-schedule"
|
|
32
33
|
import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades"
|
|
33
34
|
import { REFLECTION_DEBUG_WGSL, buildMirrorCamera } from "./reflection"
|
|
34
35
|
import { packHalf, type HdrImage } from "./hdr"
|
|
@@ -49,7 +50,6 @@ import {
|
|
|
49
50
|
MAX_LIGHTS,
|
|
50
51
|
buildLightEmitShader,
|
|
51
52
|
hasLightEmit,
|
|
52
|
-
parseLightCount,
|
|
53
53
|
} from "./shaders/lights"
|
|
54
54
|
import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground"
|
|
55
55
|
import { outlineShaderWgsl } from "./shaders/passes/outline"
|
|
@@ -66,7 +66,6 @@ import {
|
|
|
66
66
|
buildCompositeShader,
|
|
67
67
|
EFFECT_SCENE_API,
|
|
68
68
|
buildFieldShader,
|
|
69
|
-
parseEffectAnchors,
|
|
70
69
|
EFFECT_ANCHORS,
|
|
71
70
|
EFFECT_SUBJECTS,
|
|
72
71
|
EFFECT_TRAIL_BASE,
|
|
@@ -75,9 +74,6 @@ import {
|
|
|
75
74
|
import {
|
|
76
75
|
buildParticleComputeShader,
|
|
77
76
|
buildParticleRenderShader,
|
|
78
|
-
parseParticleBlend,
|
|
79
|
-
parseParticleBloom,
|
|
80
|
-
parseParticleCount,
|
|
81
77
|
particleEntryPoints,
|
|
82
78
|
PARTICLE_STRIDE,
|
|
83
79
|
} from "./shaders/passes/particles"
|
|
@@ -85,7 +81,6 @@ import {
|
|
|
85
81
|
SIM_FORMAT,
|
|
86
82
|
GRID_MAX,
|
|
87
83
|
buildSimShader,
|
|
88
|
-
parseGridSize,
|
|
89
84
|
gridEntryPoint,
|
|
90
85
|
} from "./shaders/passes/grid"
|
|
91
86
|
import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails"
|
|
@@ -101,6 +96,7 @@ import type {
|
|
|
101
96
|
StyleGroup,
|
|
102
97
|
} from "./graph/style-group"
|
|
103
98
|
import { DEFAULT_GRAPH } from "./graph/presets/default"
|
|
99
|
+
import { parseDirectives, stripDirectives, type EffectDirectives, type EffectParamDecl } from "./shaders/directives"
|
|
104
100
|
import { UNLIT_GRAPH } from "./graph/presets/unlit"
|
|
105
101
|
import { FACE_GRAPH } from "./graph/presets/face"
|
|
106
102
|
import { HAIR_GRAPH } from "./graph/presets/hair"
|
|
@@ -379,6 +375,18 @@ export type EffectResult = {
|
|
|
379
375
|
/** Which mounts the WGSL declared — `fn background` / `fn foreground`. Both
|
|
380
376
|
* false only on a failed compile, since defining neither IS the failure. */
|
|
381
377
|
mounts: { background: boolean; foreground: boolean }
|
|
378
|
+
/** The knobs this effect exposes, from its own `#param` lines — name, type,
|
|
379
|
+
* default and any range. A host builds controls from THIS rather than from
|
|
380
|
+
* a second parse of the source, so what the panel offers and what the shader
|
|
381
|
+
* reads cannot come apart. Empty when the effect declares none. */
|
|
382
|
+
params: EffectParamDecl[]
|
|
383
|
+
/** How long ONE firing lasts, seconds, from `#duration`. 0 = the effect
|
|
384
|
+
* declared none and is AMBIENT — a condition the scene is in rather than
|
|
385
|
+
* something that happens at a moment. A host places a hit at its own length
|
|
386
|
+
* and spans an ambient one, which is the same reason `params` is here: what
|
|
387
|
+
* the host does with an effect should come from the effect, not from a
|
|
388
|
+
* second parse that can drift from it. */
|
|
389
|
+
duration: number
|
|
382
390
|
}
|
|
383
391
|
|
|
384
392
|
type CameraOptions = {
|
|
@@ -1260,13 +1268,13 @@ const FIELD_LAYER_BLEND: GPUBlendState = {
|
|
|
1260
1268
|
}
|
|
1261
1269
|
|
|
1262
1270
|
/**
|
|
1263
|
-
*
|
|
1271
|
+
* `#layer additive` — for LIGHT rather than matter.
|
|
1264
1272
|
*
|
|
1265
1273
|
* Alpha-over is right for anything with mass: smoke, fog, a backdrop. It is
|
|
1266
1274
|
* wrong for a glow, and visibly so the moment two of them cross — the later
|
|
1267
1275
|
* bolt occludes the earlier one in proportion to its own brightness, when what
|
|
1268
1276
|
* light does is get brighter. Unity and Unreal both ship exactly this split,
|
|
1269
|
-
* and the particle path here already has it as
|
|
1277
|
+
* and the particle path here already has it as `#blend additive`.
|
|
1270
1278
|
*
|
|
1271
1279
|
* Colour still scales by the author's alpha, so alpha keeps meaning "how much
|
|
1272
1280
|
* of this is here" and an effect fades out the way it always did. What changes
|
|
@@ -1295,6 +1303,12 @@ const FIELD_LAYER_BLEND_ADDITIVE: GPUBlendState = {
|
|
|
1295
1303
|
*/
|
|
1296
1304
|
interface EffectInstance {
|
|
1297
1305
|
wgsl: string
|
|
1306
|
+
/** What this instance's source declared — kept so setEffectParam can refuse a
|
|
1307
|
+
* name the effect never offered instead of writing nowhere. */
|
|
1308
|
+
paramDecls: EffectParamDecl[]
|
|
1309
|
+
/** One firing's length in seconds, from `#duration`. 0 = ambient. Reported
|
|
1310
|
+
* back at install so a host can place the effect at its own length. */
|
|
1311
|
+
duration: number
|
|
1298
1312
|
paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
|
|
1299
1313
|
paramsBuffer: GPUBuffer | null
|
|
1300
1314
|
paramsData: Float32Array<ArrayBuffer>
|
|
@@ -1321,6 +1335,26 @@ interface EffectInstance {
|
|
|
1321
1335
|
anchors: { bone: string; trail: boolean }[]
|
|
1322
1336
|
/** Where this effect's own clock started, in scene seconds. */
|
|
1323
1337
|
epochScene: number
|
|
1338
|
+
/**
|
|
1339
|
+
* The level this effect reaches, 0..1 — Blender's `influence`, and its
|
|
1340
|
+
* meaning: a strip's blends ramp toward THIS rather than toward 1, so a
|
|
1341
|
+
* permanently half-strength effect and a scheduled one are the same dial.
|
|
1342
|
+
*/
|
|
1343
|
+
influence: number
|
|
1344
|
+
/** Its strips, in scene seconds — a LANE, so one effect can fire more than
|
|
1345
|
+
* once. Null or empty = on for the whole scene, which is what applying an
|
|
1346
|
+
* effect does until someone places it. */
|
|
1347
|
+
window: readonly EffectWindow[] | null
|
|
1348
|
+
/**
|
|
1349
|
+
* What the mounts actually read this frame: `influence` shaped by the strip.
|
|
1350
|
+
*
|
|
1351
|
+
* Applied by ENGINE-GENERATED code at each mount's one output site, never by
|
|
1352
|
+
* the author's — an effect that had to honour its own weight would be an
|
|
1353
|
+
* effect that could forget to, and a scheduler cannot be built on a promise
|
|
1354
|
+
* every author has to keep. At 0 the mount's draw is skipped outright, which
|
|
1355
|
+
* is what makes a scheduled effect cost nothing outside its window.
|
|
1356
|
+
*/
|
|
1357
|
+
weight: number
|
|
1324
1358
|
/** This effect's OWN clock, as a uniform the field shader reads. Per effect
|
|
1325
1359
|
* because the shared one (viewU[6].x) is measured from the first installed
|
|
1326
1360
|
* effect's epoch, so everything later started mid-stream. Null when the
|
|
@@ -1328,7 +1362,7 @@ interface EffectInstance {
|
|
|
1328
1362
|
fieldClock: GPUBuffer | null
|
|
1329
1363
|
/** The lightEmit mount: a compute stage that writes this effect's own slots
|
|
1330
1364
|
* in the shared lights buffer, once per light per frame. Null unless the
|
|
1331
|
-
* source declares
|
|
1365
|
+
* source declares `#lights n` AND defines fn lightEmit. */
|
|
1332
1366
|
lights: {
|
|
1333
1367
|
pipeline: GPUComputePipeline
|
|
1334
1368
|
bind: GPUBindGroup
|
|
@@ -1600,7 +1634,7 @@ export class Engine {
|
|
|
1600
1634
|
* cost a degenerate quad the rasteriser rejects, which is cheaper than the
|
|
1601
1635
|
* prefix sum and readback a compacted draw list would need every frame.
|
|
1602
1636
|
*/
|
|
1603
|
-
/** Ceiling for
|
|
1637
|
+
/** Ceiling for `#particles`. Past this an author is asking for a stall. */
|
|
1604
1638
|
private static readonly MAX_PARTICLES = 65536
|
|
1605
1639
|
private particleFrame = 0
|
|
1606
1640
|
/**
|
|
@@ -1620,7 +1654,7 @@ export class Engine {
|
|
|
1620
1654
|
* RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
|
|
1621
1655
|
* composite reads them full-over-half.
|
|
1622
1656
|
*
|
|
1623
|
-
*
|
|
1657
|
+
* `#fullres` used to be a property of the shared targets: one effect
|
|
1624
1658
|
* declaring it promoted the pass for every effect installed, so a starfield
|
|
1625
1659
|
* that upsamples perfectly paid four times the pixels because a keyboard
|
|
1626
1660
|
* beside it needed crisp edges. Measured, that was the largest avoidable cost
|
|
@@ -1742,7 +1776,7 @@ export class Engine {
|
|
|
1742
1776
|
*
|
|
1743
1777
|
* `field` earns its place now that a scene runs SEVERAL field effects at
|
|
1744
1778
|
* once: it is one pass with N draws, its resolution is a property of the
|
|
1745
|
-
* shared targets rather than of any one effect — so a single
|
|
1779
|
+
* shared targets rather than of any one effect — so a single `#fullres`
|
|
1746
1780
|
* effect quadruples the pixel count for all of them — and it is the pass the
|
|
1747
1781
|
* field restructure moves. Restructuring it while it was the only untimed
|
|
1748
1782
|
* pass in the frame would have meant reasoning about the cost instead of
|
|
@@ -1860,8 +1894,21 @@ export class Engine {
|
|
|
1860
1894
|
// 360 backdrop (equirectangular skybox, sampled by view ray in composite).
|
|
1861
1895
|
private backdropEquirectTexture: GPUTexture | null = null
|
|
1862
1896
|
private backdropEquirectView: GPUTextureView | null = null
|
|
1863
|
-
|
|
1864
|
-
|
|
1897
|
+
/**
|
|
1898
|
+
* The HDRI WORLD — what lights the scene, and what you see when nothing else
|
|
1899
|
+
* is behind it.
|
|
1900
|
+
*
|
|
1901
|
+
* Separate from the backdrop because they answer different questions. An
|
|
1902
|
+
* HDRI is a measurement of light: it drives the ambient term through
|
|
1903
|
+
* `worldSH` whether or not it is the thing on screen. A 360 picture is
|
|
1904
|
+
* wallpaper: it is what you see and it lights nothing. They shared one slot
|
|
1905
|
+
* and so were mutually exclusive, which made "light her with a studio HDRI
|
|
1906
|
+
* and put a different sky behind her" impossible to say — the ordinary split
|
|
1907
|
+
* every renderer draws between a world and a film backdrop.
|
|
1908
|
+
*/
|
|
1909
|
+
private worldEquirectTexture: GPUTexture | null = null
|
|
1910
|
+
private worldEquirectView: GPUTextureView | null = null
|
|
1911
|
+
private worldStrength = 1
|
|
1865
1912
|
/** The installed HDRI's folded irradiance SH (27 floats), or null. */
|
|
1866
1913
|
private worldSH: Float32Array | null = null
|
|
1867
1914
|
private fallbackEquirectTexture!: GPUTexture
|
|
@@ -2239,13 +2286,17 @@ export class Engine {
|
|
|
2239
2286
|
// In modes 2 and 3 the colour slot is dead, so mode 3 carries the world
|
|
2240
2287
|
// STRENGTH in u[8] — Blender's world-strength dial, default 1.
|
|
2241
2288
|
const bg = this.backgroundColor
|
|
2242
|
-
|
|
2289
|
+
// THE BACKDROP WINS WHAT YOU SEE; the world lights regardless. With only a
|
|
2290
|
+
// world installed it is also the sky, which is what an HDRI alone has
|
|
2291
|
+
// always done.
|
|
2292
|
+
const showingWorld = this.backdropEquirectView === null && this.worldEquirectView !== null
|
|
2293
|
+
u[8] = showingWorld ? this.worldStrength : (bg?.x ?? 0)
|
|
2243
2294
|
u[9] = bg?.y ?? 0
|
|
2244
2295
|
u[10] = bg?.z ?? 0
|
|
2245
2296
|
// Base-layer mode only. A user effect is a separate LAYER over whichever
|
|
2246
2297
|
// base is active, and needs no flag of its own: the composite pipeline is
|
|
2247
2298
|
// rebuilt per effect, so the compiled variant IS the flag.
|
|
2248
|
-
u[11] = this.backdropEquirectView ?
|
|
2299
|
+
u[11] = this.backdropEquirectView ? 2 : showingWorld ? 3 : bg ? 1 : 0
|
|
2249
2300
|
// Which display transform forms the frame (see viewTransform in composite.ts).
|
|
2250
2301
|
u[25] = v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0
|
|
2251
2302
|
u[26] = this.canvas.width
|
|
@@ -2694,7 +2745,10 @@ export class Engine {
|
|
|
2694
2745
|
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
2695
2746
|
{ binding: 4, resource: this.maskResolveView },
|
|
2696
2747
|
{ binding: 5, resource: this.filmicLutView },
|
|
2697
|
-
|
|
2748
|
+
// Whichever equirect is SHOWING — the backdrop if there is one, the
|
|
2749
|
+
// world otherwise. The world's light does not come through here; it
|
|
2750
|
+
// rides worldSH into the material shells.
|
|
2751
|
+
{ binding: 6, resource: this.backdropEquirectView ?? this.worldEquirectView ?? this.fallbackEquirectView },
|
|
2698
2752
|
{ binding: 7, resource: { buffer: this.effect?.paramsBuffer ?? this.bgParamsDummyBuffer } },
|
|
2699
2753
|
{ binding: 8, resource: this.depthReadView },
|
|
2700
2754
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
@@ -2879,27 +2933,29 @@ export class Engine {
|
|
|
2879
2933
|
* affects lighting, bloom, or tonemapping. Pass null to remove (the background
|
|
2880
2934
|
* color, or transparency, takes over again).
|
|
2881
2935
|
*/
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
this.
|
|
2936
|
+
/**
|
|
2937
|
+
* The HDRI world: what LIGHTS the scene.
|
|
2938
|
+
*
|
|
2939
|
+
* Its irradiance goes to the world seat as spherical harmonics, so it lights
|
|
2940
|
+
* whether or not it is the thing you see — and it IS the thing you see until
|
|
2941
|
+
* a backdrop is set, which is what an HDRI on its own has always done.
|
|
2942
|
+
*
|
|
2943
|
+
* `strength` is Blender's world-strength dial and is folded into the
|
|
2944
|
+
* coefficients, so what lights her is what you see.
|
|
2945
|
+
*/
|
|
2946
|
+
setWorldEquirect(source: HdrImage | null, options?: { strength?: number }): void {
|
|
2947
|
+
this.worldEquirectTexture?.destroy()
|
|
2948
|
+
this.worldEquirectTexture = null
|
|
2949
|
+
this.worldEquirectView = null
|
|
2950
|
+
this.worldStrength = Math.max(options?.strength ?? 1, 0)
|
|
2894
2951
|
const hadSH = this.worldSH !== null
|
|
2895
2952
|
this.worldSH = null
|
|
2896
|
-
if (source &&
|
|
2897
|
-
|
|
2898
|
-
//
|
|
2899
|
-
//
|
|
2900
|
-
// and the composite treats it as light rather than wallpaper (mode 3).
|
|
2953
|
+
if (source && this.device) {
|
|
2954
|
+
// Scene-linear radiance in rgba16float. The composite treats it as light
|
|
2955
|
+
// rather than wallpaper (mode 3) — a sun in it rolls off like a sun,
|
|
2956
|
+
// through the same exposure and view transform as the scene.
|
|
2901
2957
|
const tex = this.device.createTexture({
|
|
2902
|
-
label: "
|
|
2958
|
+
label: "world equirect (HDR)",
|
|
2903
2959
|
size: [source.width, source.height],
|
|
2904
2960
|
format: "rgba16float",
|
|
2905
2961
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
@@ -2910,28 +2966,42 @@ export class Engine {
|
|
|
2910
2966
|
{ bytesPerRow: source.width * 8, rowsPerImage: source.height },
|
|
2911
2967
|
[source.width, source.height],
|
|
2912
2968
|
)
|
|
2913
|
-
this.
|
|
2914
|
-
this.
|
|
2915
|
-
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
// and hand it to the world seat. The sun keeps the toon ramp — this is
|
|
2919
|
-
// the ambient term, exactly where the flat world colour used to sit.
|
|
2969
|
+
this.worldEquirectTexture = tex
|
|
2970
|
+
this.worldEquirectView = tex.createView()
|
|
2971
|
+
// The sky lights the scene, not only backs it. The sun keeps the toon
|
|
2972
|
+
// ramp — this is the ambient term, exactly where the flat world colour
|
|
2973
|
+
// used to sit.
|
|
2920
2974
|
this.worldSH = projectIrradianceSH({ ...source, data: source.data }, 4)
|
|
2921
|
-
if (this.
|
|
2922
|
-
for (let i = 0; i < this.worldSH.length; i++) this.worldSH[i] *= this.
|
|
2975
|
+
if (this.worldStrength !== 1) {
|
|
2976
|
+
for (let i = 0; i < this.worldSH.length; i++) this.worldSH[i] *= this.worldStrength
|
|
2923
2977
|
}
|
|
2924
|
-
this.writeWorld()
|
|
2925
|
-
this.rebuildCompositeBindGroup()
|
|
2926
|
-
if (this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
2927
|
-
return
|
|
2928
2978
|
}
|
|
2929
|
-
if (hadSH) this.writeWorld()
|
|
2930
|
-
|
|
2979
|
+
if (this.worldSH || hadSH) this.writeWorld()
|
|
2980
|
+
this.rebuildCompositeBindGroup()
|
|
2981
|
+
if (this.device && this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
/**
|
|
2985
|
+
* The 360 backdrop: what you SEE behind the scene.
|
|
2986
|
+
*
|
|
2987
|
+
* Wallpaper, and only wallpaper — it lights nothing. An HDRI belongs in
|
|
2988
|
+
* setWorldEquirect, which is why this no longer takes one: the two shared a
|
|
2989
|
+
* slot and were therefore mutually exclusive, and a picture that silently
|
|
2990
|
+
* changed the lighting because of its file format was a surprise nobody
|
|
2991
|
+
* asked for.
|
|
2992
|
+
*
|
|
2993
|
+
* Set alongside a world and this is what shows while the world goes on
|
|
2994
|
+
* lighting. Cleared, the world's own sky comes back.
|
|
2995
|
+
*/
|
|
2996
|
+
setBackdropEquirect(source: ImageBitmap | HTMLImageElement | HTMLCanvasElement | null): void {
|
|
2997
|
+
this.backdropEquirectTexture?.destroy()
|
|
2998
|
+
this.backdropEquirectTexture = null
|
|
2999
|
+
this.backdropEquirectView = null
|
|
3000
|
+
if (source && this.device) {
|
|
2931
3001
|
let width = Math.max(1, "naturalWidth" in source ? source.naturalWidth : source.width)
|
|
2932
3002
|
let height = Math.max(1, "naturalHeight" in source ? source.naturalHeight : source.height)
|
|
2933
3003
|
let upload: ImageBitmap | HTMLImageElement | HTMLCanvasElement | OffscreenCanvas = source
|
|
2934
|
-
// Panoramas routinely exceed maxTextureDimension2D (e.g.
|
|
3004
|
+
// Panoramas routinely exceed maxTextureDimension2D (e.g. 10000x5000 vs the
|
|
2935
3005
|
// default 8192) — quietly downscale to fit rather than surfacing an error.
|
|
2936
3006
|
const limit = this.device.limits.maxTextureDimension2D
|
|
2937
3007
|
if (width > limit || height > limit) {
|
|
@@ -3010,34 +3080,10 @@ export class Engine {
|
|
|
3010
3080
|
* is KEPT and diagnostics are returned with line numbers relative to the
|
|
3011
3081
|
* user's WGSL. Pass null to remove the effect.
|
|
3012
3082
|
*/
|
|
3013
|
-
/**
|
|
3014
|
-
* Every directive an effect may declare — used ONLY to tell an author that
|
|
3015
|
-
* the line they wrote is not doing what they think. See compileEffect.
|
|
3016
|
-
*
|
|
3017
|
-
* It has to be complete, including the ones this engine does not read itself:
|
|
3018
|
-
* `@dissolve` is parsed by the host, and warning about it would be worse than
|
|
3019
|
-
* the silence this replaced — a false alarm on a working line teaches authors
|
|
3020
|
-
* to ignore the channel.
|
|
3021
|
-
*/
|
|
3022
|
-
private static readonly KNOWN = new Set([
|
|
3023
|
-
"@anchor",
|
|
3024
|
-
"@layer",
|
|
3025
|
-
"@blend",
|
|
3026
|
-
"@bloom",
|
|
3027
|
-
"@lights",
|
|
3028
|
-
"@grid",
|
|
3029
|
-
"@particles",
|
|
3030
|
-
"@halfres",
|
|
3031
|
-
// Accepted and inert: full resolution is the default now, and an effect
|
|
3032
|
-
// that still says so is right about what it wants. Warning about it would
|
|
3033
|
-
// be telling authors off for the thing that used to be necessary.
|
|
3034
|
-
"@fullres",
|
|
3035
|
-
// The HOST's, not this engine's — see the note above.
|
|
3036
|
-
"@dissolve",
|
|
3037
|
-
])
|
|
3038
3083
|
|
|
3039
3084
|
private async compileEffect(
|
|
3040
|
-
|
|
3085
|
+
/** The author's file, directives included — parsed here and nowhere else. */
|
|
3086
|
+
authored: string,
|
|
3041
3087
|
params: Record<string, EffectParamValue> | undefined,
|
|
3042
3088
|
/** This effect's own declarations, already parsed by the caller — which had
|
|
3043
3089
|
* to read them anyway to build the scene table. */
|
|
@@ -3046,7 +3092,22 @@ export class Engine {
|
|
|
3046
3092
|
alias: number[],
|
|
3047
3093
|
): Promise<{ ok: true; instance: EffectInstance; warnings: string[] } | EffectResult> {
|
|
3048
3094
|
const noMounts = { background: false, foreground: false }
|
|
3049
|
-
if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts }
|
|
3095
|
+
if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0 }
|
|
3096
|
+
|
|
3097
|
+
// WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
|
|
3098
|
+
// rather than running a regex of its own — eight parsers over one file was
|
|
3099
|
+
// eight chances to disagree about what it said, and they did.
|
|
3100
|
+
//
|
|
3101
|
+
// An unrecognised or malformed directive is an ERROR. `#` is not WGSL
|
|
3102
|
+
// syntax, so a line starting with one is unambiguously ours and there is
|
|
3103
|
+
// nothing to be lenient about; the old spelling lived in comments, where a
|
|
3104
|
+
// typo was indistinguishable from prose and could only ever be warned about.
|
|
3105
|
+
const parsed = parseDirectives(authored)
|
|
3106
|
+
if (parsed.errors.length) return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0 }
|
|
3107
|
+
const d = parsed.directives
|
|
3108
|
+
// The compiler sees the file with its directive lines BLANKED, so every
|
|
3109
|
+
// diagnostic below still names the line the author is looking at.
|
|
3110
|
+
const wgsl = stripDirectives(authored)
|
|
3050
3111
|
|
|
3051
3112
|
// ── Which mounts did the author ask for? A declaration, not a setting: the
|
|
3052
3113
|
// entry points present in the source are the ones compiled in. Matching the
|
|
@@ -3063,14 +3124,10 @@ export class Engine {
|
|
|
3063
3124
|
const te = trailEntryPoints(wgsl)
|
|
3064
3125
|
const wantsTrails = te.width || te.shade
|
|
3065
3126
|
if (wantsTrails && !(te.width && te.shade)) {
|
|
3066
|
-
return {
|
|
3067
|
-
ok: false,
|
|
3068
|
-
diagnostics: [
|
|
3127
|
+
return { ok: false, diagnostics: [
|
|
3069
3128
|
`a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
|
|
3070
3129
|
`fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
|
|
3071
|
-
],
|
|
3072
|
-
mounts: noMounts,
|
|
3073
|
-
}
|
|
3130
|
+
], mounts: noMounts, params: [], duration: 0 }
|
|
3074
3131
|
}
|
|
3075
3132
|
if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
|
|
3076
3133
|
const missing = [
|
|
@@ -3078,7 +3135,7 @@ export class Engine {
|
|
|
3078
3135
|
pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
|
|
3079
3136
|
pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
|
|
3080
3137
|
].filter(Boolean)
|
|
3081
|
-
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts }
|
|
3138
|
+
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0 }
|
|
3082
3139
|
}
|
|
3083
3140
|
// One file, one kind — for now.
|
|
3084
3141
|
//
|
|
@@ -3093,36 +3150,28 @@ export class Engine {
|
|
|
3093
3150
|
// says so plainly instead of failing with "unresolved type Particle" from a
|
|
3094
3151
|
// pass they did not know they were compiling into.
|
|
3095
3152
|
if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
|
|
3096
|
-
return {
|
|
3097
|
-
ok: false,
|
|
3098
|
-
diagnostics: [
|
|
3153
|
+
return { ok: false, diagnostics: [
|
|
3099
3154
|
"an effect declares field mounts (background/foreground) or particles, not both — " +
|
|
3100
3155
|
"split them into two effects",
|
|
3101
|
-
],
|
|
3102
|
-
mounts: noMounts,
|
|
3103
|
-
}
|
|
3156
|
+
], mounts: noMounts, params: [], duration: 0 }
|
|
3104
3157
|
}
|
|
3105
3158
|
// lightEmit counts as a mount on its own: a pure lighting rig draws nothing
|
|
3106
3159
|
// and is still an effect — it is how a scene gets stage lights without also
|
|
3107
3160
|
// getting geometry it did not ask for.
|
|
3108
3161
|
if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
|
|
3109
|
-
return {
|
|
3110
|
-
ok: false,
|
|
3111
|
-
diagnostics: [
|
|
3162
|
+
return { ok: false, diagnostics: [
|
|
3112
3163
|
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
|
|
3113
3164
|
"fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
|
|
3114
3165
|
"the particle trio (particleInit/particleStep/particleShade), " +
|
|
3115
3166
|
"the ribbon pair (trailWidth/trailShade), " +
|
|
3116
|
-
"or fn lightEmit(i: u32) -> RzLight with
|
|
3117
|
-
],
|
|
3118
|
-
mounts: noMounts,
|
|
3119
|
-
}
|
|
3167
|
+
"or fn lightEmit(i: u32) -> RzLight with #lights <n>",
|
|
3168
|
+
], mounts: noMounts, params: [], duration: 0 }
|
|
3120
3169
|
}
|
|
3121
3170
|
const mounts = { background: hasBackground, foreground: hasForeground }
|
|
3122
3171
|
|
|
3123
3172
|
// ── Directives only some mounts honour ──
|
|
3124
3173
|
//
|
|
3125
|
-
//
|
|
3174
|
+
// #bloom sets the aux mask, and only the particle and ribbon modules write
|
|
3126
3175
|
// that mask: they draw inside the scene pass, in HDR, while the bloom
|
|
3127
3176
|
// pyramid can still see them. A field effect composites in DISPLAY space
|
|
3128
3177
|
// after tone mapping, so there is nothing left to pick it up and the
|
|
@@ -3135,32 +3184,9 @@ export class Engine {
|
|
|
3135
3184
|
// pinning an effect that declares this has to keep installing; saying so is
|
|
3136
3185
|
// all that was ever missing.
|
|
3137
3186
|
const warnings: string[] = []
|
|
3138
|
-
|
|
3139
|
-
//
|
|
3140
|
-
// Every pragma is matched with `\s*$` after it, so a line that carries a
|
|
3141
|
-
// note as well — `// @fullres — glyph edges are sub-pixel detail` — matches
|
|
3142
|
-
// none of them and is read as an ordinary comment. Nothing failed, nothing
|
|
3143
|
-
// said anything, and the effect simply ran without the property it asked
|
|
3144
|
-
// for: three shipped effects were silently half-res and a fourth silently
|
|
3145
|
-
// stopped being additive, each one's first line explaining why it needed
|
|
3146
|
-
// the thing it was not getting.
|
|
3147
|
-
//
|
|
3148
|
-
// Every directive this engine knows, so an unrecognised one is named rather
|
|
3149
|
-
// than ignored. Cheap: it runs once per install, over a file a human wrote.
|
|
3150
|
-
for (const m of wgsl.matchAll(/^[ \t]*\/\/[ \t]*(@[a-zA-Z]+)(.*)$/gm)) {
|
|
3151
|
-
const [, tag, rest] = m
|
|
3152
|
-
if (!Engine.KNOWN.has(tag)) {
|
|
3153
|
-
warnings.push(`${tag} is not a directive this engine knows — it will be ignored.`)
|
|
3154
|
-
} else if (rest.trim() && !/^\s*[\w.\-+]+(\s+[\w.\-+]+)*\s*$/.test(rest)) {
|
|
3155
|
-
warnings.push(
|
|
3156
|
-
`${tag} has a note on the same line, so it does not parse and is being IGNORED. ` +
|
|
3157
|
-
`A directive must be alone on its line — put the note on the next one.`,
|
|
3158
|
-
)
|
|
3159
|
-
}
|
|
3160
|
-
}
|
|
3161
|
-
if (parseParticleBloom(wgsl) && !wantsParticles && !wantsTrails) {
|
|
3187
|
+
if (d.bloom && !wantsParticles && !wantsTrails) {
|
|
3162
3188
|
warnings.push(
|
|
3163
|
-
"
|
|
3189
|
+
"#bloom does nothing here. A field effect (background/foreground) composites after tone " +
|
|
3164
3190
|
"mapping, past the bloom pyramid — the directive applies to particles and ribbons, which draw " +
|
|
3165
3191
|
"in HDR inside the scene pass. Make the effect's own falloff brighter instead.",
|
|
3166
3192
|
)
|
|
@@ -3183,7 +3209,7 @@ export class Engine {
|
|
|
3183
3209
|
let cursor = 0
|
|
3184
3210
|
for (const [name, value] of entries) {
|
|
3185
3211
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
3186
|
-
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts }
|
|
3212
|
+
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration }
|
|
3187
3213
|
}
|
|
3188
3214
|
const isVec = typeof value !== "number"
|
|
3189
3215
|
const align = isVec ? 16 : 4
|
|
@@ -3212,7 +3238,7 @@ export class Engine {
|
|
|
3212
3238
|
// module (buildFieldShader), so a bad effect can no longer produce errors at
|
|
3213
3239
|
// line numbers in a shader the author never wrote — and installing one no
|
|
3214
3240
|
// longer recompiles the composite's tone-mapping half at all.
|
|
3215
|
-
const gridSize = gridEntryPoint(wgsl) ?
|
|
3241
|
+
const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0
|
|
3216
3242
|
// `alias` goes in: a field effect reads bones through _rzSlot exactly as a
|
|
3217
3243
|
// particle one does, and it was the only module never handed the mapping.
|
|
3218
3244
|
const fieldEffect =
|
|
@@ -3221,11 +3247,11 @@ export class Engine {
|
|
|
3221
3247
|
this.device.pushErrorScope("validation")
|
|
3222
3248
|
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
|
|
3223
3249
|
const scopeErr = await this.device.popErrorScope()
|
|
3224
|
-
if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts }
|
|
3250
|
+
if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration }
|
|
3225
3251
|
|
|
3226
3252
|
// Declared like every other mount property: by what the source says, not by
|
|
3227
3253
|
// a setting somewhere else that an author cannot see from the file.
|
|
3228
|
-
const layerBlend =
|
|
3254
|
+
const layerBlend = d.additiveLayer
|
|
3229
3255
|
? FIELD_LAYER_BLEND_ADDITIVE
|
|
3230
3256
|
: FIELD_LAYER_BLEND
|
|
3231
3257
|
let fieldPipeline: GPURenderPipeline | null = null
|
|
@@ -3240,7 +3266,7 @@ export class Engine {
|
|
|
3240
3266
|
.filter((m) => m.type === "error")
|
|
3241
3267
|
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
3242
3268
|
if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
|
|
3243
|
-
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
|
|
3269
|
+
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
|
|
3244
3270
|
try {
|
|
3245
3271
|
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
3246
3272
|
label: "field layer pipeline",
|
|
@@ -3266,7 +3292,7 @@ export class Engine {
|
|
|
3266
3292
|
multisample: { count: 1 },
|
|
3267
3293
|
})
|
|
3268
3294
|
} catch (e) {
|
|
3269
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
|
|
3295
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration }
|
|
3270
3296
|
}
|
|
3271
3297
|
}
|
|
3272
3298
|
let identity: GPURenderPipeline
|
|
@@ -3290,7 +3316,7 @@ export class Engine {
|
|
|
3290
3316
|
make(true, "composite pipeline (effect, gamma!=1)"),
|
|
3291
3317
|
])
|
|
3292
3318
|
} catch (e) {
|
|
3293
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
|
|
3319
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration }
|
|
3294
3320
|
}
|
|
3295
3321
|
|
|
3296
3322
|
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
@@ -3306,17 +3332,17 @@ export class Engine {
|
|
|
3306
3332
|
grid?.textures[1].destroy()
|
|
3307
3333
|
grid?.uniform.destroy()
|
|
3308
3334
|
trails?.uniform.destroy()
|
|
3309
|
-
return { ok: false, diagnostics, mounts }
|
|
3335
|
+
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
|
|
3310
3336
|
}
|
|
3311
3337
|
let particles: EffectParticles | null = null
|
|
3312
3338
|
if (wantsParticles) {
|
|
3313
|
-
const built = await this.buildParticles(wgsl, anchors, alias)
|
|
3339
|
+
const built = await this.buildParticles(wgsl, d, anchors, alias)
|
|
3314
3340
|
if (!built.ok) return abandon(built.diagnostics)
|
|
3315
3341
|
particles = built.state
|
|
3316
3342
|
}
|
|
3317
3343
|
let grid: EffectGrid | null = null
|
|
3318
3344
|
if (gridEntryPoint(wgsl)) {
|
|
3319
|
-
const built = await this.buildSim(wgsl, anchors, alias)
|
|
3345
|
+
const built = await this.buildSim(wgsl, d, anchors, alias)
|
|
3320
3346
|
if (!built.ok) return abandon(built.diagnostics)
|
|
3321
3347
|
grid = built.state
|
|
3322
3348
|
}
|
|
@@ -3326,13 +3352,9 @@ export class Engine {
|
|
|
3326
3352
|
// bone recorded without one would read zeroes and paint a line to the origin.
|
|
3327
3353
|
const trailSlots = anchors.filter((a) => a.trail).length
|
|
3328
3354
|
if (trailSlots === 0) {
|
|
3329
|
-
return {
|
|
3330
|
-
ok: false,
|
|
3331
|
-
diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
|
|
3332
|
-
mounts,
|
|
3333
|
-
}
|
|
3355
|
+
return { ok: false, diagnostics: ["a ribbon effect needs at least one #anchor <bone> trail"], mounts, params: d.params, duration: d.duration }
|
|
3334
3356
|
}
|
|
3335
|
-
const built = await this.buildTrails(wgsl, anchors, alias)
|
|
3357
|
+
const built = await this.buildTrails(wgsl, d, anchors, alias)
|
|
3336
3358
|
if (!built.ok) return abandon(built.diagnostics)
|
|
3337
3359
|
trails = built.state
|
|
3338
3360
|
}
|
|
@@ -3345,13 +3367,13 @@ export class Engine {
|
|
|
3345
3367
|
// count is a function nothing calls. Either alone is a silent blank, which
|
|
3346
3368
|
// is the worst way for an effect to fail.
|
|
3347
3369
|
let lights: EffectInstance["lights"] = null
|
|
3348
|
-
const declaredLights =
|
|
3370
|
+
const declaredLights = Math.min(d.lights, MAX_LIGHTS)
|
|
3349
3371
|
const emits = hasLightEmit(wgsl)
|
|
3350
3372
|
if (declaredLights > 0 !== emits) {
|
|
3351
3373
|
return abandon([
|
|
3352
3374
|
emits
|
|
3353
|
-
? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with
|
|
3354
|
-
: "
|
|
3375
|
+
? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with #lights <n>"
|
|
3376
|
+
: "#lights <n> needs fn lightEmit(i: u32) -> RzLight to fill those slots",
|
|
3355
3377
|
])
|
|
3356
3378
|
}
|
|
3357
3379
|
if (declaredLights > 0) {
|
|
@@ -3380,6 +3402,8 @@ export class Engine {
|
|
|
3380
3402
|
}
|
|
3381
3403
|
const instance: EffectInstance = {
|
|
3382
3404
|
wgsl,
|
|
3405
|
+
paramDecls: d.params,
|
|
3406
|
+
duration: d.duration,
|
|
3383
3407
|
paramLayout: layout,
|
|
3384
3408
|
paramsBuffer,
|
|
3385
3409
|
paramsData,
|
|
@@ -3394,6 +3418,12 @@ export class Engine {
|
|
|
3394
3418
|
// The effect's own clock starts now. Per effect so that one installed
|
|
3395
3419
|
// later still gets a frame where rzGridFrame() is 0 and can seed.
|
|
3396
3420
|
epochScene: this.sceneClock,
|
|
3421
|
+
// Fully on, unscheduled. An effect that is installed is showing;
|
|
3422
|
+
// scheduling it is something a caller does afterwards, and an install
|
|
3423
|
+
// that silently began at zero would look like a compile that failed.
|
|
3424
|
+
influence: 1,
|
|
3425
|
+
window: null,
|
|
3426
|
+
weight: 1,
|
|
3397
3427
|
// Its OWN resolution, no longer the scene's: an effect that never asked
|
|
3398
3428
|
// for full res is not promoted because a neighbour did.
|
|
3399
3429
|
// FULL RESOLUTION UNLESS TOLD OTHERWISE.
|
|
@@ -3406,12 +3436,12 @@ export class Engine {
|
|
|
3406
3436
|
// wearing a different hat: the safe answer has to be the one you get
|
|
3407
3437
|
// for saying nothing.
|
|
3408
3438
|
//
|
|
3409
|
-
// The cost is real and is why the half layer stays:
|
|
3439
|
+
// The cost is real and is why the half layer stays: `#halfres` is worth
|
|
3410
3440
|
// about 3.7x on a full-screen effect (Footprints, measured, 1.2ms
|
|
3411
3441
|
// against 4.5ms). It is the right call for a soft additive glow, which
|
|
3412
3442
|
// upsamples invisibly — and it is now a claim an author makes about
|
|
3413
3443
|
// their own effect rather than a fate that befalls one.
|
|
3414
|
-
fieldLayer:
|
|
3444
|
+
fieldLayer: d.fieldLayer,
|
|
3415
3445
|
fieldPipeline,
|
|
3416
3446
|
fieldClock,
|
|
3417
3447
|
// Filled by rebuildFieldBindGroup below, which needs the instance to
|
|
@@ -3453,7 +3483,7 @@ export class Engine {
|
|
|
3453
3483
|
list: { wgsl: string; params?: Record<string, EffectParamValue> }[] | null,
|
|
3454
3484
|
): Promise<EffectResult[]> {
|
|
3455
3485
|
const noMounts = { background: false, foreground: false }
|
|
3456
|
-
if (!this.device) return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts }]
|
|
3486
|
+
if (!this.device) return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }]
|
|
3457
3487
|
|
|
3458
3488
|
const requested = list ?? []
|
|
3459
3489
|
if (requested.length === 0) {
|
|
@@ -3479,7 +3509,14 @@ export class Engine {
|
|
|
3479
3509
|
|
|
3480
3510
|
// One table for the whole scene, built before anything compiles: an effect's
|
|
3481
3511
|
// alias is its row, and a bone two effects both name is allocated once.
|
|
3482
|
-
|
|
3512
|
+
// The table needs every effect's anchors before any of them compiles, so
|
|
3513
|
+
// this is the one place a source is read twice — compileEffect parses it
|
|
3514
|
+
// again for everything else. A malformed file yields no anchors here and
|
|
3515
|
+
// fails with its real diagnostics there, which is the right order: the
|
|
3516
|
+
// error names the line, not the table.
|
|
3517
|
+
const perEffectAnchors = requested.map((e) =>
|
|
3518
|
+
parseDirectives(e.wgsl).directives.anchors.slice(0, MAX_EFFECT_ANCHORS),
|
|
3519
|
+
)
|
|
3483
3520
|
const table = buildAnchorTable(perEffectAnchors, MAX_EFFECT_ANCHORS)
|
|
3484
3521
|
|
|
3485
3522
|
const results: EffectResult[] = []
|
|
@@ -3499,6 +3536,8 @@ export class Engine {
|
|
|
3499
3536
|
instances.push(built.instance)
|
|
3500
3537
|
results.push({
|
|
3501
3538
|
ok: true,
|
|
3539
|
+
params: built.instance.paramDecls,
|
|
3540
|
+
duration: built.instance.duration,
|
|
3502
3541
|
// Installed, and still with something to say — a directive that parsed
|
|
3503
3542
|
// but will never fire. Same channel as the dropped-anchor note below.
|
|
3504
3543
|
diagnostics: built.warnings,
|
|
@@ -3563,7 +3602,7 @@ export class Engine {
|
|
|
3563
3602
|
this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)")
|
|
3564
3603
|
this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)")
|
|
3565
3604
|
|
|
3566
|
-
// Nothing to promote any more:
|
|
3605
|
+
// Nothing to promote any more: `#fullres` is per effect, read into
|
|
3567
3606
|
// fieldLayer when the instance is built, and both target pairs exist for
|
|
3568
3607
|
// the life of the surface. What used to be a scene-wide decision made here
|
|
3569
3608
|
// is now each effect's own.
|
|
@@ -3581,14 +3620,18 @@ export class Engine {
|
|
|
3581
3620
|
const noMounts = { background: false, foreground: false }
|
|
3582
3621
|
if (wgsl === null) {
|
|
3583
3622
|
await this.setEffects(null)
|
|
3584
|
-
return { ok: true, diagnostics: [], mounts: noMounts }
|
|
3623
|
+
return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 }
|
|
3585
3624
|
}
|
|
3586
3625
|
const [result] = await this.setEffects([{ wgsl, params }])
|
|
3587
|
-
return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts }
|
|
3626
|
+
return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0 }
|
|
3588
3627
|
}
|
|
3589
3628
|
|
|
3590
3629
|
private async buildParticles(
|
|
3630
|
+
/** Already stripped of directives — see compileEffect. */
|
|
3591
3631
|
wgsl: string,
|
|
3632
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
3633
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
3634
|
+
d: EffectDirectives,
|
|
3592
3635
|
anchors: { bone: string; trail: boolean }[],
|
|
3593
3636
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
3594
3637
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
@@ -3597,8 +3640,8 @@ export class Engine {
|
|
|
3597
3640
|
): Promise<{ ok: true; state: EffectParticles } | { ok: false; diagnostics: string[] }> {
|
|
3598
3641
|
// No pragma means "some": an author who wrote the trio clearly wants
|
|
3599
3642
|
// particles, and failing over a missing comment would be pedantry.
|
|
3600
|
-
const count =
|
|
3601
|
-
const src = { wgsl, count, blend:
|
|
3643
|
+
const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES)
|
|
3644
|
+
const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom }
|
|
3602
3645
|
// Sparks want to spawn where a trail is, so the particle stages see the same
|
|
3603
3646
|
// cast buffer the trail draw reads.
|
|
3604
3647
|
const cast = {
|
|
@@ -3636,10 +3679,13 @@ export class Engine {
|
|
|
3636
3679
|
})
|
|
3637
3680
|
const uniform = this.device.createBuffer({
|
|
3638
3681
|
label: "particle uniforms",
|
|
3639
|
-
|
|
3682
|
+
// Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
|
|
3683
|
+
// The first was exactly full, and weight has to live in the same buffer
|
|
3684
|
+
// as the clock or a frame could draw one without the other.
|
|
3685
|
+
size: 32,
|
|
3640
3686
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
3641
3687
|
})
|
|
3642
|
-
const uniformBytes = new ArrayBuffer(
|
|
3688
|
+
const uniformBytes = new ArrayBuffer(32)
|
|
3643
3689
|
const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) }
|
|
3644
3690
|
|
|
3645
3691
|
// Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
|
|
@@ -3856,12 +3902,18 @@ export class Engine {
|
|
|
3856
3902
|
private emitLights(encoder: GPUCommandEncoder): void {
|
|
3857
3903
|
for (const e of this.effects) {
|
|
3858
3904
|
const l = e.lights
|
|
3905
|
+
// NOT skipped at weight 0, unlike every other mount. Each effect writes
|
|
3906
|
+
// its OWN slots in a shared buffer that is never cleared, so a skipped
|
|
3907
|
+
// dispatch leaves last frame's lights burning — the one place where not
|
|
3908
|
+
// running is the wrong answer. The shader zeroes them instead, and the
|
|
3909
|
+
// dispatch it costs is a single workgroup.
|
|
3859
3910
|
if (!l || l.data[2] === 0) continue
|
|
3860
3911
|
// The effect's OWN epoch — the same one its field, particle, ribbon and
|
|
3861
3912
|
// grid halves now read. This was briefly conditional, to match a field
|
|
3862
3913
|
// clock that was shared from the first installed effect; that clock is
|
|
3863
3914
|
// per effect now, so every mount in one file agrees by construction.
|
|
3864
3915
|
l.data[0] = this.sceneClock - e.epochScene
|
|
3916
|
+
l.data[3] = e.weight
|
|
3865
3917
|
this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer as ArrayBuffer)
|
|
3866
3918
|
const cp = encoder.beginComputePass({ label: "light emit" })
|
|
3867
3919
|
cp.setPipeline(l.pipeline)
|
|
@@ -3876,6 +3928,11 @@ export class Engine {
|
|
|
3876
3928
|
const p = e.particles
|
|
3877
3929
|
if (!p) continue
|
|
3878
3930
|
p.data[0] = this.sceneClock - e.epochScene
|
|
3931
|
+
// The SIMULATION runs at every weight, 0 included — only the draw stops.
|
|
3932
|
+
// A scheduled effect that froze while faded out would resume from the
|
|
3933
|
+
// state it left rather than the one it would have reached, so fading one
|
|
3934
|
+
// back in would rewind it.
|
|
3935
|
+
p.data[4] = e.weight
|
|
3879
3936
|
// Clamped: a backgrounded tab returns with a delta of whole seconds, and an
|
|
3880
3937
|
// unclamped step flings every particle out of the scene in one frame.
|
|
3881
3938
|
p.data[1] = Math.min(0.1, Math.max(0, deltaTime))
|
|
@@ -3894,7 +3951,7 @@ export class Engine {
|
|
|
3894
3951
|
private renderParticles(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
|
|
3895
3952
|
for (const e of this.effects) {
|
|
3896
3953
|
const p = e.particles
|
|
3897
|
-
if (!p) continue
|
|
3954
|
+
if (!p || e.weight === 0) continue
|
|
3898
3955
|
pass.setPipeline(p.render)
|
|
3899
3956
|
pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind)
|
|
3900
3957
|
pass.draw(6, p.count)
|
|
@@ -3909,7 +3966,11 @@ export class Engine {
|
|
|
3909
3966
|
* frame on the CPU.
|
|
3910
3967
|
*/
|
|
3911
3968
|
private async buildTrails(
|
|
3969
|
+
/** Already stripped of directives — see compileEffect. */
|
|
3912
3970
|
wgsl: string,
|
|
3971
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
3972
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
3973
|
+
d: EffectDirectives,
|
|
3913
3974
|
anchors: { bone: string; trail: boolean }[],
|
|
3914
3975
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
3915
3976
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
@@ -3925,7 +3986,7 @@ export class Engine {
|
|
|
3925
3986
|
// nothing before: ribbon i was read as anchor slot i.
|
|
3926
3987
|
const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0)
|
|
3927
3988
|
const slots = ribbonSlots.length
|
|
3928
|
-
const src = { wgsl, slots, ribbonSlots, blend:
|
|
3989
|
+
const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom }
|
|
3929
3990
|
const code = buildTrailShader(src, {
|
|
3930
3991
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
3931
3992
|
samples: TRAIL_SAMPLES,
|
|
@@ -4062,7 +4123,7 @@ export class Engine {
|
|
|
4062
4123
|
* Takes the pass rather than opening one: that IS the change.
|
|
4063
4124
|
*/
|
|
4064
4125
|
private drawTrails(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
|
|
4065
|
-
const drawn = this.effects.filter((e) => e.trails)
|
|
4126
|
+
const drawn = this.effects.filter((e) => e.trails && e.weight > 0)
|
|
4066
4127
|
if (drawn.length === 0) return
|
|
4067
4128
|
for (const e of drawn) {
|
|
4068
4129
|
const t = e.trails!
|
|
@@ -4085,6 +4146,7 @@ export class Engine {
|
|
|
4085
4146
|
if (view === "camera") {
|
|
4086
4147
|
t.data[0] = this.sceneClock - e.epochScene
|
|
4087
4148
|
t.data[1] = live
|
|
4149
|
+
t.data[2] = e.weight
|
|
4088
4150
|
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
|
|
4089
4151
|
}
|
|
4090
4152
|
pass.setPipeline(t.pipeline)
|
|
@@ -4097,14 +4159,31 @@ export class Engine {
|
|
|
4097
4159
|
* upsample. Runs the whole quad — uniform control flow, so effects may use
|
|
4098
4160
|
* derivatives freely, which the old inline path had to forbid. */
|
|
4099
4161
|
private renderFieldPass(encoder: GPUCommandEncoder): void {
|
|
4100
|
-
|
|
4101
|
-
|
|
4162
|
+
// TWO PREDICATES, deliberately, and they are not interchangeable.
|
|
4163
|
+
//
|
|
4164
|
+
// MOUNTED decides whether the pass runs, and it must agree exactly with
|
|
4165
|
+
// fieldPairUsed — that is what the composite's bind group was built against,
|
|
4166
|
+
// at install, and it is not rebuilt per frame. A pass skipped under a
|
|
4167
|
+
// binding that still points at its target leaves the last frame it drew
|
|
4168
|
+
// sitting there, so an effect faded to nothing would freeze on screen
|
|
4169
|
+
// instead of disappearing.
|
|
4170
|
+
//
|
|
4171
|
+
// DRAWN decides what is drawn into it, and this is where weight is worth
|
|
4172
|
+
// something: a field mount is a full-screen quad however little of the frame
|
|
4173
|
+
// it ends up touching, so an effect that is scheduled off would otherwise
|
|
4174
|
+
// shade every pixel to multiply it out to nothing. The pass still clears —
|
|
4175
|
+
// which is what makes the layer transparent rather than stale — and shades
|
|
4176
|
+
// nothing.
|
|
4177
|
+
const mounted = this.effects.filter((e) => e.fieldPipeline && e.fieldBindGroups)
|
|
4178
|
+
if (mounted.length === 0) return
|
|
4179
|
+
const drawn = mounted.filter((e) => e.weight > 0)
|
|
4102
4180
|
// Each effect's own clock, before the pass that reads it. Seconds since
|
|
4103
4181
|
// THIS effect was installed — so an effect added to a running scene starts
|
|
4104
4182
|
// at zero and can seed, rather than joining whatever the first one is up to.
|
|
4105
4183
|
for (const e of drawn) {
|
|
4106
4184
|
if (!e.fieldClock) continue
|
|
4107
4185
|
this.fieldClockScratch[0] = this.sceneClock - e.epochScene
|
|
4186
|
+
this.fieldClockScratch[1] = e.weight
|
|
4108
4187
|
this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer as ArrayBuffer)
|
|
4109
4188
|
}
|
|
4110
4189
|
// ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
|
|
@@ -4119,7 +4198,7 @@ export class Engine {
|
|
|
4119
4198
|
// (fieldLayerView). Clearing and storing an empty full-res rgba16f pair is
|
|
4120
4199
|
// two 16MB writes a frame to produce the transparent black the fallback
|
|
4121
4200
|
// already is. Most scenes leave the full-res pair empty, since an effect only
|
|
4122
|
-
// lands there by declaring
|
|
4201
|
+
// lands there by declaring #fullres.
|
|
4123
4202
|
let stamped = false
|
|
4124
4203
|
for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
|
|
4125
4204
|
const bg = this.fieldBgViews[i]
|
|
@@ -4132,7 +4211,7 @@ export class Engine {
|
|
|
4132
4211
|
{ view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
4133
4212
|
],
|
|
4134
4213
|
// One query pair is reserved for "field", and it goes to the first pair
|
|
4135
|
-
// that actually runs — full res when something declared
|
|
4214
|
+
// that actually runs — full res when something declared #fullres, half
|
|
4136
4215
|
// otherwise. Pinning it to i === 0 would have measured a pass that, now
|
|
4137
4216
|
// that empty pairs are skipped, usually does not happen.
|
|
4138
4217
|
timestampWrites: stamped ? undefined : this.stamps("field"),
|
|
@@ -4197,14 +4276,18 @@ export class Engine {
|
|
|
4197
4276
|
* zero, so seeding is just "if frame is 0, return the initial state".
|
|
4198
4277
|
*/
|
|
4199
4278
|
private async buildSim(
|
|
4279
|
+
/** Already stripped of directives — see compileEffect. */
|
|
4200
4280
|
wgsl: string,
|
|
4281
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
4282
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
4283
|
+
d: EffectDirectives,
|
|
4201
4284
|
anchors: { bone: string; trail: boolean }[],
|
|
4202
4285
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
4203
4286
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
4204
4287
|
* describes the effect that is still on screen. */
|
|
4205
4288
|
alias: number[],
|
|
4206
4289
|
): Promise<{ ok: true; state: EffectGrid } | { ok: false; diagnostics: string[] }> {
|
|
4207
|
-
const size =
|
|
4290
|
+
const size = Math.min(d.grid || 256, GRID_MAX)
|
|
4208
4291
|
const cast = {
|
|
4209
4292
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
4210
4293
|
samples: TRAIL_SAMPLES,
|
|
@@ -4343,10 +4426,19 @@ export class Engine {
|
|
|
4343
4426
|
return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false }
|
|
4344
4427
|
}
|
|
4345
4428
|
|
|
4346
|
-
/**
|
|
4347
|
-
*
|
|
4348
|
-
|
|
4349
|
-
|
|
4429
|
+
/**
|
|
4430
|
+
* Set one parameter on one INSTANCE.
|
|
4431
|
+
*
|
|
4432
|
+
* By index, because the scene holds a list and the same effect may appear in
|
|
4433
|
+
* it twice with different values — which is the whole point of an instance
|
|
4434
|
+
* and was impossible while this addressed `this.effect`, a singular left over
|
|
4435
|
+
* from when a scene could wear exactly one.
|
|
4436
|
+
*
|
|
4437
|
+
* A write, not a recompile: parameters live in their own uniform buffer, so
|
|
4438
|
+
* dragging a slider costs a 16-byte upload rather than a shader build.
|
|
4439
|
+
*/
|
|
4440
|
+
setEffectParam(index: number, name: string, value: EffectParamValue): void {
|
|
4441
|
+
const fx = this.effects[index]
|
|
4350
4442
|
if (!fx || !fx.paramsBuffer) return
|
|
4351
4443
|
const slot = fx.paramLayout.get(name)
|
|
4352
4444
|
if (!slot) return
|
|
@@ -4359,6 +4451,119 @@ export class Engine {
|
|
|
4359
4451
|
this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData)
|
|
4360
4452
|
}
|
|
4361
4453
|
|
|
4454
|
+
/**
|
|
4455
|
+
* How much of one instance is showing, 0..1.
|
|
4456
|
+
*
|
|
4457
|
+
* The third of the three things an instance has — parameters, weight, time —
|
|
4458
|
+
* and the one a scheduler drives. Weight is not a parameter: a parameter is
|
|
4459
|
+
* whatever the author decided to expose and means only what their source
|
|
4460
|
+
* makes it mean, while weight means the same thing for every effect ever
|
|
4461
|
+
* written, including one whose author never heard of it. That is why it is
|
|
4462
|
+
* applied by engine-generated code at each mount's output rather than handed
|
|
4463
|
+
* to the source as a uniform to respect.
|
|
4464
|
+
*
|
|
4465
|
+
* At 0 nothing is drawn: no field quad, no particle draw, no ribbon, no light
|
|
4466
|
+
* dispatch. A scheduled effect outside its window costs its simulation and
|
|
4467
|
+
* nothing else — and a particle effect keeps simulating on purpose, so that
|
|
4468
|
+
* fading one back in continues rather than rewinds.
|
|
4469
|
+
*
|
|
4470
|
+
* Instant, and free: a float in a uniform every mount already uploads once a
|
|
4471
|
+
* frame. Nothing recompiles, so this is safe to drive per frame from a
|
|
4472
|
+
* timeline.
|
|
4473
|
+
*/
|
|
4474
|
+
setEffectInfluence(index: number, influence: number): void {
|
|
4475
|
+
const fx = this.effects[index]
|
|
4476
|
+
if (!fx) return
|
|
4477
|
+
// Clamped rather than trusted: above 1 the field's own clamp would swallow
|
|
4478
|
+
// it while an additive particle would happily keep getting brighter, so the
|
|
4479
|
+
// same number would mean two things.
|
|
4480
|
+
fx.influence = Math.min(1, Math.max(0, influence))
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
getEffectInfluence(index: number): number {
|
|
4484
|
+
return this.effects[index]?.influence ?? 0
|
|
4485
|
+
}
|
|
4486
|
+
|
|
4487
|
+
/**
|
|
4488
|
+
* Schedule one instance: when it is alive, and how it enters and leaves.
|
|
4489
|
+
*
|
|
4490
|
+
* Null is the unscheduled case — on for the whole scene, on the scene's own
|
|
4491
|
+
* clock — and is what an effect starts as.
|
|
4492
|
+
*
|
|
4493
|
+
* The engine evaluates this every frame rather than taking a weight from a
|
|
4494
|
+
* caller, because every loop that renders would otherwise have to remember to
|
|
4495
|
+
* drive it. The offline export loop already carries a scar about exactly that
|
|
4496
|
+
* shape of bug. Evaluating where the scene clock advances means playback and
|
|
4497
|
+
* export cannot disagree, and neither can forget.
|
|
4498
|
+
*
|
|
4499
|
+
* A caller that wants to drive an effect from something OTHER than the scene
|
|
4500
|
+
* clock — an animation's progress, a skill firing — leaves this null and
|
|
4501
|
+
* writes setEffectInfluence and setEffectTime itself, per frame. Both paths
|
|
4502
|
+
* exist on purpose; this one is what a timeline wants.
|
|
4503
|
+
*/
|
|
4504
|
+
setEffectSchedule(index: number, windows: readonly EffectWindow[] | null): void {
|
|
4505
|
+
const fx = this.effects[index]
|
|
4506
|
+
if (!fx) return
|
|
4507
|
+
fx.window = windows && windows.length ? windows : null
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
getEffectSchedule(index: number): readonly EffectWindow[] | null {
|
|
4511
|
+
return this.effects[index]?.window ?? null
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4514
|
+
/**
|
|
4515
|
+
* Every scheduled effect, at the current scene clock.
|
|
4516
|
+
*
|
|
4517
|
+
* Called once a frame, BEFORE anything reads a weight or a clock. An effect
|
|
4518
|
+
* with no window keeps whatever a caller last set, which is what makes the
|
|
4519
|
+
* manual path above work — evaluating it would fight the caller for the field
|
|
4520
|
+
* every frame.
|
|
4521
|
+
*/
|
|
4522
|
+
private evaluateEffectSchedules(): void {
|
|
4523
|
+
// Read ONCE: it walks the cast, and every effect wants the same answer.
|
|
4524
|
+
const transport = this.transportTime()
|
|
4525
|
+
for (const fx of this.effects) {
|
|
4526
|
+
if (!fx.window || fx.window.length === 0) {
|
|
4527
|
+
fx.weight = fx.influence
|
|
4528
|
+
continue
|
|
4529
|
+
}
|
|
4530
|
+
const at = effectState(fx.window, fx.influence, transport)
|
|
4531
|
+
fx.weight = at.weight
|
|
4532
|
+
// Its own clock, expressed the way the mounts read it. Every mount
|
|
4533
|
+
// derives time from the epoch against sceneClock, so this one write moves
|
|
4534
|
+
// the field, the particles, the ribbons, lightEmit and the grid together
|
|
4535
|
+
// — and hands them the STRIP's local time while they keep running on the
|
|
4536
|
+
// smooth monotonic clock a particle integrator needs.
|
|
4537
|
+
fx.epochScene = this.sceneClock - at.time
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
|
|
4541
|
+
/**
|
|
4542
|
+
* Move one instance's own clock to a given second.
|
|
4543
|
+
*
|
|
4544
|
+
* Everything an effect can animate is derived from its epoch — the field
|
|
4545
|
+
* clock, the particle and ribbon clocks, lightEmit's time argument, the grid's
|
|
4546
|
+
* frame counter — so moving the epoch moves all of them together and there is
|
|
4547
|
+
* no mount that can be left reading last frame's time.
|
|
4548
|
+
*
|
|
4549
|
+
* This is what lets an effect be SCHEDULED rather than merely switched on: an
|
|
4550
|
+
* instance that enters at bar 33 is handed a time that starts at zero there,
|
|
4551
|
+
* so it plays its own opening instead of joining whatever the scene clock had
|
|
4552
|
+
* reached. Feeding it the transport's time instead gives the other reading —
|
|
4553
|
+
* an effect that runs in lockstep with the music — and both are one call.
|
|
4554
|
+
*/
|
|
4555
|
+
setEffectTime(index: number, time: number): void {
|
|
4556
|
+
const fx = this.effects[index]
|
|
4557
|
+
if (!fx) return
|
|
4558
|
+
fx.epochScene = this.sceneClock - time
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
|
|
4562
|
+
getEffectTime(index: number): number {
|
|
4563
|
+
const fx = this.effects[index]
|
|
4564
|
+
return fx ? this.sceneClock - fx.epochScene : 0
|
|
4565
|
+
}
|
|
4566
|
+
|
|
4362
4567
|
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
4363
4568
|
/** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
|
|
4364
4569
|
* the scene pass only stores its depth buffer on frames the gather reads. */
|
|
@@ -6020,7 +6225,7 @@ export class Engine {
|
|
|
6020
6225
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
6021
6226
|
})
|
|
6022
6227
|
|
|
6023
|
-
// The field layer — half resolution by default, full for
|
|
6228
|
+
// The field layer — half resolution by default, full for #fullres effects.
|
|
6024
6229
|
this.fieldFullW = width
|
|
6025
6230
|
this.fieldFullH = height
|
|
6026
6231
|
this.createFieldTargets()
|
|
@@ -6904,10 +7109,24 @@ export class Engine {
|
|
|
6904
7109
|
this.camera.setVmdDriven(false)
|
|
6905
7110
|
}
|
|
6906
7111
|
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
7112
|
+
/**
|
|
7113
|
+
* THE TRANSPORT'S CLOCK — where the scene is in its own playback.
|
|
7114
|
+
*
|
|
7115
|
+
* The first model with an active clip (playing or scrubbed), so a static stage
|
|
7116
|
+
* never freezes it at frame 0. Falls back to the first model with a clip, then
|
|
7117
|
+
* to 0 for an empty scene.
|
|
7118
|
+
*
|
|
7119
|
+
* NOT `sceneClock`, and the difference is the whole reason this has a name.
|
|
7120
|
+
* `sceneClock` only ever accumulates delta — it is how long the engine has
|
|
7121
|
+
* been running, it does not move when you scrub, and it does not stop when you
|
|
7122
|
+
* pause. Anything that should line up with what the transport shows has to
|
|
7123
|
+
* read THIS. An effect scheduled to frame 100 against sceneClock fires once,
|
|
7124
|
+
* a hundred frames after the page loaded, and never again.
|
|
7125
|
+
*
|
|
7126
|
+
* Deterministic offline: the export loop advances model animation by an exact
|
|
7127
|
+
* per-frame delta, so this reproduces frame for frame.
|
|
7128
|
+
*/
|
|
7129
|
+
private transportTime(): number {
|
|
6911
7130
|
let fallback: number | null = null
|
|
6912
7131
|
for (const inst of this.modelInstances.values()) {
|
|
6913
7132
|
// Stages are skipped outright. Scenery carries no motion, and it is added
|
|
@@ -10558,7 +10777,7 @@ export class Engine {
|
|
|
10558
10777
|
|
|
10559
10778
|
// Drive the shot from the camera VMD (synced to the animated model's clock).
|
|
10560
10779
|
if (this.camera.vmdDriven && this.cameraAnimation) {
|
|
10561
|
-
const pose = this.cameraAnimation.sample(this.
|
|
10780
|
+
const pose = this.cameraAnimation.sample(this.transportTime())
|
|
10562
10781
|
if (pose) this.camera.setVmdPose(pose)
|
|
10563
10782
|
}
|
|
10564
10783
|
|
|
@@ -10693,6 +10912,12 @@ export class Engine {
|
|
|
10693
10912
|
// uniforms this frame.
|
|
10694
10913
|
this.evaluateDissolveCycles()
|
|
10695
10914
|
this.evaluateParamTracks()
|
|
10915
|
+
// FIRST among the things that read an effect, because every one of them
|
|
10916
|
+
// reads what this writes: the sim's clock, the particle uniform's weight,
|
|
10917
|
+
// the light dispatch, the field draw. Evaluated here rather than by a
|
|
10918
|
+
// caller so that playback, the export loop and a warm-up pass cannot
|
|
10919
|
+
// disagree about when an effect is alive — none of them has to remember it.
|
|
10920
|
+
this.evaluateEffectSchedules()
|
|
10696
10921
|
this.stepSim(encoder, deltaTime)
|
|
10697
10922
|
this.stepParticles(encoder, deltaTime)
|
|
10698
10923
|
// Before the scene pass, which READS the slots this writes. Same buffer,
|
|
@@ -10754,7 +10979,7 @@ export class Engine {
|
|
|
10754
10979
|
this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView))
|
|
10755
10980
|
// Last in the pass: depth-tested against everything drawn above, so a
|
|
10756
10981
|
// particle behind the character is simply hidden, and still inside the HDR
|
|
10757
|
-
// target so an
|
|
10982
|
+
// target so an `#bloom` effect reaches the pyramid below.
|
|
10758
10983
|
this.renderParticles(pass, "camera")
|
|
10759
10984
|
// Ribbons, in the same pass and after the particles: both are additive
|
|
10760
10985
|
// light in HDR, and both reach the bloom pyramid because of it. This used
|