reze-engine 0.50.12 → 0.52.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 +184 -7
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +500 -96
- package/dist/graph/presets/unlit.d.ts +3 -0
- package/dist/graph/presets/unlit.d.ts.map +1 -0
- package/dist/graph/presets/unlit.js +34 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- 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/lyrics-api.d.ts +11 -2
- package/dist/shaders/lyrics-api.d.ts.map +1 -1
- package/dist/shaders/lyrics-api.js +11 -2
- 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 +588 -95
- package/src/graph/presets/unlit.ts +37 -0
- package/src/index.ts +16 -0
- 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/lyrics-api.ts +11 -2
- 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
|
@@ -2,7 +2,7 @@ import { Camera } from "./camera"
|
|
|
2
2
|
import { decodeDds, isDds } from "./dds-loader"
|
|
3
3
|
import { Mat4, Quat, Vec3 } from "./math"
|
|
4
4
|
import { decodePsd, isPsd } from "./psd-loader"
|
|
5
|
-
import { Model, MATERIAL_MORPH_MULTIPLY, type Material } from "./model"
|
|
5
|
+
import { Model, MATERIAL_MORPH_MULTIPLY, type Material, type Skeleton } from "./model"
|
|
6
6
|
import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
|
|
7
7
|
import { CULL_COMPUTE_WGSL } from "./shaders/passes/cull"
|
|
8
8
|
import { buildAnchorTable, anchorAliasWgsl, EMPTY_ANCHOR_TABLE, type AnchorTable } from "./shaders/anchor-table"
|
|
@@ -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,8 @@ 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"
|
|
100
|
+
import { UNLIT_GRAPH } from "./graph/presets/unlit"
|
|
104
101
|
import { FACE_GRAPH } from "./graph/presets/face"
|
|
105
102
|
import { HAIR_GRAPH } from "./graph/presets/hair"
|
|
106
103
|
import { BODY_GRAPH } from "./graph/presets/body"
|
|
@@ -378,6 +375,18 @@ export type EffectResult = {
|
|
|
378
375
|
/** Which mounts the WGSL declared — `fn background` / `fn foreground`. Both
|
|
379
376
|
* false only on a failed compile, since defining neither IS the failure. */
|
|
380
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
|
|
381
390
|
}
|
|
382
391
|
|
|
383
392
|
type CameraOptions = {
|
|
@@ -719,6 +728,21 @@ interface ModelInstance {
|
|
|
719
728
|
/** Environment geometry added via addStage — no physics, no IK, and it
|
|
720
729
|
* suppresses the built-in ground. See addStage for why each of those. */
|
|
721
730
|
isStage: boolean
|
|
731
|
+
/**
|
|
732
|
+
* A media plane: a flat card carrying a picture.
|
|
733
|
+
*
|
|
734
|
+
* Its OWN flag rather than a shade of isStage. The two overlap in what they
|
|
735
|
+
* skip — neither performs, so neither wants physics, IK, the cast buffer or
|
|
736
|
+
* the camera clock — but they disagree on the thing a stage exists for: a
|
|
737
|
+
* stage IS the floor and suppresses the built-in ground, while a card is
|
|
738
|
+
* scenery standing in the scene and must leave the floor alone. Folding a
|
|
739
|
+
* plane into isStage would have made adding a title graphic delete the ground.
|
|
740
|
+
*/
|
|
741
|
+
isPlane: boolean
|
|
742
|
+
/** This card's texture is rewritten every frame, so it is allocated with no
|
|
743
|
+
* mip chain — rebuilding one per frame is a pass per level per card, and is
|
|
744
|
+
* what a moving card was mostly costing. See setPlaneFrame. */
|
|
745
|
+
dynamicTexture: boolean
|
|
722
746
|
/** A pose pass ran since the last skin-matrix upload. Always true for cast
|
|
723
747
|
* members; false for an idle stage, which is the point. */
|
|
724
748
|
skinMatricesDirty: boolean
|
|
@@ -1244,13 +1268,13 @@ const FIELD_LAYER_BLEND: GPUBlendState = {
|
|
|
1244
1268
|
}
|
|
1245
1269
|
|
|
1246
1270
|
/**
|
|
1247
|
-
*
|
|
1271
|
+
* `#layer additive` — for LIGHT rather than matter.
|
|
1248
1272
|
*
|
|
1249
1273
|
* Alpha-over is right for anything with mass: smoke, fog, a backdrop. It is
|
|
1250
1274
|
* wrong for a glow, and visibly so the moment two of them cross — the later
|
|
1251
1275
|
* bolt occludes the earlier one in proportion to its own brightness, when what
|
|
1252
1276
|
* light does is get brighter. Unity and Unreal both ship exactly this split,
|
|
1253
|
-
* and the particle path here already has it as
|
|
1277
|
+
* and the particle path here already has it as `#blend additive`.
|
|
1254
1278
|
*
|
|
1255
1279
|
* Colour still scales by the author's alpha, so alpha keeps meaning "how much
|
|
1256
1280
|
* of this is here" and an effect fades out the way it always did. What changes
|
|
@@ -1279,6 +1303,12 @@ const FIELD_LAYER_BLEND_ADDITIVE: GPUBlendState = {
|
|
|
1279
1303
|
*/
|
|
1280
1304
|
interface EffectInstance {
|
|
1281
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
|
|
1282
1312
|
paramLayout: Map<string, { offset: number; comps: 1 | 3 }>
|
|
1283
1313
|
paramsBuffer: GPUBuffer | null
|
|
1284
1314
|
paramsData: Float32Array<ArrayBuffer>
|
|
@@ -1305,6 +1335,26 @@ interface EffectInstance {
|
|
|
1305
1335
|
anchors: { bone: string; trail: boolean }[]
|
|
1306
1336
|
/** Where this effect's own clock started, in scene seconds. */
|
|
1307
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
|
|
1308
1358
|
/** This effect's OWN clock, as a uniform the field shader reads. Per effect
|
|
1309
1359
|
* because the shared one (viewU[6].x) is measured from the first installed
|
|
1310
1360
|
* effect's epoch, so everything later started mid-stream. Null when the
|
|
@@ -1312,7 +1362,7 @@ interface EffectInstance {
|
|
|
1312
1362
|
fieldClock: GPUBuffer | null
|
|
1313
1363
|
/** The lightEmit mount: a compute stage that writes this effect's own slots
|
|
1314
1364
|
* in the shared lights buffer, once per light per frame. Null unless the
|
|
1315
|
-
* source declares
|
|
1365
|
+
* source declares `#lights n` AND defines fn lightEmit. */
|
|
1316
1366
|
lights: {
|
|
1317
1367
|
pipeline: GPUComputePipeline
|
|
1318
1368
|
bind: GPUBindGroup
|
|
@@ -1584,7 +1634,7 @@ export class Engine {
|
|
|
1584
1634
|
* cost a degenerate quad the rasteriser rejects, which is cheaper than the
|
|
1585
1635
|
* prefix sum and readback a compacted draw list would need every frame.
|
|
1586
1636
|
*/
|
|
1587
|
-
/** Ceiling for
|
|
1637
|
+
/** Ceiling for `#particles`. Past this an author is asking for a stall. */
|
|
1588
1638
|
private static readonly MAX_PARTICLES = 65536
|
|
1589
1639
|
private particleFrame = 0
|
|
1590
1640
|
/**
|
|
@@ -1604,7 +1654,7 @@ export class Engine {
|
|
|
1604
1654
|
* RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
|
|
1605
1655
|
* composite reads them full-over-half.
|
|
1606
1656
|
*
|
|
1607
|
-
*
|
|
1657
|
+
* `#fullres` used to be a property of the shared targets: one effect
|
|
1608
1658
|
* declaring it promoted the pass for every effect installed, so a starfield
|
|
1609
1659
|
* that upsamples perfectly paid four times the pixels because a keyboard
|
|
1610
1660
|
* beside it needed crisp edges. Measured, that was the largest avoidable cost
|
|
@@ -1726,7 +1776,7 @@ export class Engine {
|
|
|
1726
1776
|
*
|
|
1727
1777
|
* `field` earns its place now that a scene runs SEVERAL field effects at
|
|
1728
1778
|
* once: it is one pass with N draws, its resolution is a property of the
|
|
1729
|
-
* shared targets rather than of any one effect — so a single
|
|
1779
|
+
* shared targets rather than of any one effect — so a single `#fullres`
|
|
1730
1780
|
* effect quadruples the pixel count for all of them — and it is the pass the
|
|
1731
1781
|
* field restructure moves. Restructuring it while it was the only untimed
|
|
1732
1782
|
* pass in the frame would have meant reasoning about the cost instead of
|
|
@@ -2994,8 +3044,10 @@ export class Engine {
|
|
|
2994
3044
|
* is KEPT and diagnostics are returned with line numbers relative to the
|
|
2995
3045
|
* user's WGSL. Pass null to remove the effect.
|
|
2996
3046
|
*/
|
|
3047
|
+
|
|
2997
3048
|
private async compileEffect(
|
|
2998
|
-
|
|
3049
|
+
/** The author's file, directives included — parsed here and nowhere else. */
|
|
3050
|
+
authored: string,
|
|
2999
3051
|
params: Record<string, EffectParamValue> | undefined,
|
|
3000
3052
|
/** This effect's own declarations, already parsed by the caller — which had
|
|
3001
3053
|
* to read them anyway to build the scene table. */
|
|
@@ -3004,7 +3056,22 @@ export class Engine {
|
|
|
3004
3056
|
alias: number[],
|
|
3005
3057
|
): Promise<{ ok: true; instance: EffectInstance; warnings: string[] } | EffectResult> {
|
|
3006
3058
|
const noMounts = { background: false, foreground: false }
|
|
3007
|
-
if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts }
|
|
3059
|
+
if (!this.device) return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0 }
|
|
3060
|
+
|
|
3061
|
+
// WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
|
|
3062
|
+
// rather than running a regex of its own — eight parsers over one file was
|
|
3063
|
+
// eight chances to disagree about what it said, and they did.
|
|
3064
|
+
//
|
|
3065
|
+
// An unrecognised or malformed directive is an ERROR. `#` is not WGSL
|
|
3066
|
+
// syntax, so a line starting with one is unambiguously ours and there is
|
|
3067
|
+
// nothing to be lenient about; the old spelling lived in comments, where a
|
|
3068
|
+
// typo was indistinguishable from prose and could only ever be warned about.
|
|
3069
|
+
const parsed = parseDirectives(authored)
|
|
3070
|
+
if (parsed.errors.length) return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0 }
|
|
3071
|
+
const d = parsed.directives
|
|
3072
|
+
// The compiler sees the file with its directive lines BLANKED, so every
|
|
3073
|
+
// diagnostic below still names the line the author is looking at.
|
|
3074
|
+
const wgsl = stripDirectives(authored)
|
|
3008
3075
|
|
|
3009
3076
|
// ── Which mounts did the author ask for? A declaration, not a setting: the
|
|
3010
3077
|
// entry points present in the source are the ones compiled in. Matching the
|
|
@@ -3021,14 +3088,10 @@ export class Engine {
|
|
|
3021
3088
|
const te = trailEntryPoints(wgsl)
|
|
3022
3089
|
const wantsTrails = te.width || te.shade
|
|
3023
3090
|
if (wantsTrails && !(te.width && te.shade)) {
|
|
3024
|
-
return {
|
|
3025
|
-
ok: false,
|
|
3026
|
-
diagnostics: [
|
|
3091
|
+
return { ok: false, diagnostics: [
|
|
3027
3092
|
`a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
|
|
3028
3093
|
`fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
|
|
3029
|
-
],
|
|
3030
|
-
mounts: noMounts,
|
|
3031
|
-
}
|
|
3094
|
+
], mounts: noMounts, params: [], duration: 0 }
|
|
3032
3095
|
}
|
|
3033
3096
|
if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
|
|
3034
3097
|
const missing = [
|
|
@@ -3036,7 +3099,7 @@ export class Engine {
|
|
|
3036
3099
|
pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
|
|
3037
3100
|
pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
|
|
3038
3101
|
].filter(Boolean)
|
|
3039
|
-
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts }
|
|
3102
|
+
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0 }
|
|
3040
3103
|
}
|
|
3041
3104
|
// One file, one kind — for now.
|
|
3042
3105
|
//
|
|
@@ -3051,36 +3114,28 @@ export class Engine {
|
|
|
3051
3114
|
// says so plainly instead of failing with "unresolved type Particle" from a
|
|
3052
3115
|
// pass they did not know they were compiling into.
|
|
3053
3116
|
if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
|
|
3054
|
-
return {
|
|
3055
|
-
ok: false,
|
|
3056
|
-
diagnostics: [
|
|
3117
|
+
return { ok: false, diagnostics: [
|
|
3057
3118
|
"an effect declares field mounts (background/foreground) or particles, not both — " +
|
|
3058
3119
|
"split them into two effects",
|
|
3059
|
-
],
|
|
3060
|
-
mounts: noMounts,
|
|
3061
|
-
}
|
|
3120
|
+
], mounts: noMounts, params: [], duration: 0 }
|
|
3062
3121
|
}
|
|
3063
3122
|
// lightEmit counts as a mount on its own: a pure lighting rig draws nothing
|
|
3064
3123
|
// and is still an effect — it is how a scene gets stage lights without also
|
|
3065
3124
|
// getting geometry it did not ask for.
|
|
3066
3125
|
if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
|
|
3067
|
-
return {
|
|
3068
|
-
ok: false,
|
|
3069
|
-
diagnostics: [
|
|
3126
|
+
return { ok: false, diagnostics: [
|
|
3070
3127
|
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
|
|
3071
3128
|
"fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
|
|
3072
3129
|
"the particle trio (particleInit/particleStep/particleShade), " +
|
|
3073
3130
|
"the ribbon pair (trailWidth/trailShade), " +
|
|
3074
|
-
"or fn lightEmit(i: u32) -> RzLight with
|
|
3075
|
-
],
|
|
3076
|
-
mounts: noMounts,
|
|
3077
|
-
}
|
|
3131
|
+
"or fn lightEmit(i: u32) -> RzLight with #lights <n>",
|
|
3132
|
+
], mounts: noMounts, params: [], duration: 0 }
|
|
3078
3133
|
}
|
|
3079
3134
|
const mounts = { background: hasBackground, foreground: hasForeground }
|
|
3080
3135
|
|
|
3081
3136
|
// ── Directives only some mounts honour ──
|
|
3082
3137
|
//
|
|
3083
|
-
//
|
|
3138
|
+
// #bloom sets the aux mask, and only the particle and ribbon modules write
|
|
3084
3139
|
// that mask: they draw inside the scene pass, in HDR, while the bloom
|
|
3085
3140
|
// pyramid can still see them. A field effect composites in DISPLAY space
|
|
3086
3141
|
// after tone mapping, so there is nothing left to pick it up and the
|
|
@@ -3093,9 +3148,9 @@ export class Engine {
|
|
|
3093
3148
|
// pinning an effect that declares this has to keep installing; saying so is
|
|
3094
3149
|
// all that was ever missing.
|
|
3095
3150
|
const warnings: string[] = []
|
|
3096
|
-
if (
|
|
3151
|
+
if (d.bloom && !wantsParticles && !wantsTrails) {
|
|
3097
3152
|
warnings.push(
|
|
3098
|
-
"
|
|
3153
|
+
"#bloom does nothing here. A field effect (background/foreground) composites after tone " +
|
|
3099
3154
|
"mapping, past the bloom pyramid — the directive applies to particles and ribbons, which draw " +
|
|
3100
3155
|
"in HDR inside the scene pass. Make the effect's own falloff brighter instead.",
|
|
3101
3156
|
)
|
|
@@ -3118,7 +3173,7 @@ export class Engine {
|
|
|
3118
3173
|
let cursor = 0
|
|
3119
3174
|
for (const [name, value] of entries) {
|
|
3120
3175
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
3121
|
-
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts }
|
|
3176
|
+
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration }
|
|
3122
3177
|
}
|
|
3123
3178
|
const isVec = typeof value !== "number"
|
|
3124
3179
|
const align = isVec ? 16 : 4
|
|
@@ -3147,7 +3202,7 @@ export class Engine {
|
|
|
3147
3202
|
// module (buildFieldShader), so a bad effect can no longer produce errors at
|
|
3148
3203
|
// line numbers in a shader the author never wrote — and installing one no
|
|
3149
3204
|
// longer recompiles the composite's tone-mapping half at all.
|
|
3150
|
-
const gridSize = gridEntryPoint(wgsl) ?
|
|
3205
|
+
const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0
|
|
3151
3206
|
// `alias` goes in: a field effect reads bones through _rzSlot exactly as a
|
|
3152
3207
|
// particle one does, and it was the only module never handed the mapping.
|
|
3153
3208
|
const fieldEffect =
|
|
@@ -3156,11 +3211,11 @@ export class Engine {
|
|
|
3156
3211
|
this.device.pushErrorScope("validation")
|
|
3157
3212
|
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source })
|
|
3158
3213
|
const scopeErr = await this.device.popErrorScope()
|
|
3159
|
-
if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts }
|
|
3214
|
+
if (scopeErr) return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration }
|
|
3160
3215
|
|
|
3161
3216
|
// Declared like every other mount property: by what the source says, not by
|
|
3162
3217
|
// a setting somewhere else that an author cannot see from the file.
|
|
3163
|
-
const layerBlend =
|
|
3218
|
+
const layerBlend = d.additiveLayer
|
|
3164
3219
|
? FIELD_LAYER_BLEND_ADDITIVE
|
|
3165
3220
|
: FIELD_LAYER_BLEND
|
|
3166
3221
|
let fieldPipeline: GPURenderPipeline | null = null
|
|
@@ -3175,7 +3230,7 @@ export class Engine {
|
|
|
3175
3230
|
.filter((m) => m.type === "error")
|
|
3176
3231
|
.map((m) => `${Math.max(0, m.lineNum - userLineOffset)}:${m.linePos} ${m.message}`)
|
|
3177
3232
|
if (diagnostics.length === 0 && fieldScopeErr) diagnostics.push(fieldScopeErr.message)
|
|
3178
|
-
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts }
|
|
3233
|
+
if (diagnostics.length > 0) return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
|
|
3179
3234
|
try {
|
|
3180
3235
|
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
3181
3236
|
label: "field layer pipeline",
|
|
@@ -3201,7 +3256,7 @@ export class Engine {
|
|
|
3201
3256
|
multisample: { count: 1 },
|
|
3202
3257
|
})
|
|
3203
3258
|
} catch (e) {
|
|
3204
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
|
|
3259
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration }
|
|
3205
3260
|
}
|
|
3206
3261
|
}
|
|
3207
3262
|
let identity: GPURenderPipeline
|
|
@@ -3225,7 +3280,7 @@ export class Engine {
|
|
|
3225
3280
|
make(true, "composite pipeline (effect, gamma!=1)"),
|
|
3226
3281
|
])
|
|
3227
3282
|
} catch (e) {
|
|
3228
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts }
|
|
3283
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration }
|
|
3229
3284
|
}
|
|
3230
3285
|
|
|
3231
3286
|
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
@@ -3241,17 +3296,17 @@ export class Engine {
|
|
|
3241
3296
|
grid?.textures[1].destroy()
|
|
3242
3297
|
grid?.uniform.destroy()
|
|
3243
3298
|
trails?.uniform.destroy()
|
|
3244
|
-
return { ok: false, diagnostics, mounts }
|
|
3299
|
+
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration }
|
|
3245
3300
|
}
|
|
3246
3301
|
let particles: EffectParticles | null = null
|
|
3247
3302
|
if (wantsParticles) {
|
|
3248
|
-
const built = await this.buildParticles(wgsl, anchors, alias)
|
|
3303
|
+
const built = await this.buildParticles(wgsl, d, anchors, alias)
|
|
3249
3304
|
if (!built.ok) return abandon(built.diagnostics)
|
|
3250
3305
|
particles = built.state
|
|
3251
3306
|
}
|
|
3252
3307
|
let grid: EffectGrid | null = null
|
|
3253
3308
|
if (gridEntryPoint(wgsl)) {
|
|
3254
|
-
const built = await this.buildSim(wgsl, anchors, alias)
|
|
3309
|
+
const built = await this.buildSim(wgsl, d, anchors, alias)
|
|
3255
3310
|
if (!built.ok) return abandon(built.diagnostics)
|
|
3256
3311
|
grid = built.state
|
|
3257
3312
|
}
|
|
@@ -3261,13 +3316,9 @@ export class Engine {
|
|
|
3261
3316
|
// bone recorded without one would read zeroes and paint a line to the origin.
|
|
3262
3317
|
const trailSlots = anchors.filter((a) => a.trail).length
|
|
3263
3318
|
if (trailSlots === 0) {
|
|
3264
|
-
return {
|
|
3265
|
-
ok: false,
|
|
3266
|
-
diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
|
|
3267
|
-
mounts,
|
|
3268
|
-
}
|
|
3319
|
+
return { ok: false, diagnostics: ["a ribbon effect needs at least one #anchor <bone> trail"], mounts, params: d.params, duration: d.duration }
|
|
3269
3320
|
}
|
|
3270
|
-
const built = await this.buildTrails(wgsl, anchors, alias)
|
|
3321
|
+
const built = await this.buildTrails(wgsl, d, anchors, alias)
|
|
3271
3322
|
if (!built.ok) return abandon(built.diagnostics)
|
|
3272
3323
|
trails = built.state
|
|
3273
3324
|
}
|
|
@@ -3280,13 +3331,13 @@ export class Engine {
|
|
|
3280
3331
|
// count is a function nothing calls. Either alone is a silent blank, which
|
|
3281
3332
|
// is the worst way for an effect to fail.
|
|
3282
3333
|
let lights: EffectInstance["lights"] = null
|
|
3283
|
-
const declaredLights =
|
|
3334
|
+
const declaredLights = Math.min(d.lights, MAX_LIGHTS)
|
|
3284
3335
|
const emits = hasLightEmit(wgsl)
|
|
3285
3336
|
if (declaredLights > 0 !== emits) {
|
|
3286
3337
|
return abandon([
|
|
3287
3338
|
emits
|
|
3288
|
-
? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with
|
|
3289
|
-
: "
|
|
3339
|
+
? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with #lights <n>"
|
|
3340
|
+
: "#lights <n> needs fn lightEmit(i: u32) -> RzLight to fill those slots",
|
|
3290
3341
|
])
|
|
3291
3342
|
}
|
|
3292
3343
|
if (declaredLights > 0) {
|
|
@@ -3315,6 +3366,8 @@ export class Engine {
|
|
|
3315
3366
|
}
|
|
3316
3367
|
const instance: EffectInstance = {
|
|
3317
3368
|
wgsl,
|
|
3369
|
+
paramDecls: d.params,
|
|
3370
|
+
duration: d.duration,
|
|
3318
3371
|
paramLayout: layout,
|
|
3319
3372
|
paramsBuffer,
|
|
3320
3373
|
paramsData,
|
|
@@ -3329,9 +3382,30 @@ export class Engine {
|
|
|
3329
3382
|
// The effect's own clock starts now. Per effect so that one installed
|
|
3330
3383
|
// later still gets a frame where rzGridFrame() is 0 and can seed.
|
|
3331
3384
|
epochScene: this.sceneClock,
|
|
3385
|
+
// Fully on, unscheduled. An effect that is installed is showing;
|
|
3386
|
+
// scheduling it is something a caller does afterwards, and an install
|
|
3387
|
+
// that silently began at zero would look like a compile that failed.
|
|
3388
|
+
influence: 1,
|
|
3389
|
+
window: null,
|
|
3390
|
+
weight: 1,
|
|
3332
3391
|
// Its OWN resolution, no longer the scene's: an effect that never asked
|
|
3333
3392
|
// for full res is not promoted because a neighbour did.
|
|
3334
|
-
|
|
3393
|
+
// FULL RESOLUTION UNLESS TOLD OTHERWISE.
|
|
3394
|
+
//
|
|
3395
|
+
// It was the other way round, and the default was the bug. An author
|
|
3396
|
+
// who has never heard of the flag writes an effect with an edge in it
|
|
3397
|
+
// and gets a soft one — nothing fails, nothing warns, because nothing
|
|
3398
|
+
// was declared to fail. Three shipped effects DID declare it and were
|
|
3399
|
+
// half-res anyway on a parsing technicality, which is the same bug
|
|
3400
|
+
// wearing a different hat: the safe answer has to be the one you get
|
|
3401
|
+
// for saying nothing.
|
|
3402
|
+
//
|
|
3403
|
+
// The cost is real and is why the half layer stays: `#halfres` is worth
|
|
3404
|
+
// about 3.7x on a full-screen effect (Footprints, measured, 1.2ms
|
|
3405
|
+
// against 4.5ms). It is the right call for a soft additive glow, which
|
|
3406
|
+
// upsamples invisibly — and it is now a claim an author makes about
|
|
3407
|
+
// their own effect rather than a fate that befalls one.
|
|
3408
|
+
fieldLayer: d.fieldLayer,
|
|
3335
3409
|
fieldPipeline,
|
|
3336
3410
|
fieldClock,
|
|
3337
3411
|
// Filled by rebuildFieldBindGroup below, which needs the instance to
|
|
@@ -3373,7 +3447,7 @@ export class Engine {
|
|
|
3373
3447
|
list: { wgsl: string; params?: Record<string, EffectParamValue> }[] | null,
|
|
3374
3448
|
): Promise<EffectResult[]> {
|
|
3375
3449
|
const noMounts = { background: false, foreground: false }
|
|
3376
|
-
if (!this.device) return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts }]
|
|
3450
|
+
if (!this.device) return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }]
|
|
3377
3451
|
|
|
3378
3452
|
const requested = list ?? []
|
|
3379
3453
|
if (requested.length === 0) {
|
|
@@ -3399,7 +3473,14 @@ export class Engine {
|
|
|
3399
3473
|
|
|
3400
3474
|
// One table for the whole scene, built before anything compiles: an effect's
|
|
3401
3475
|
// alias is its row, and a bone two effects both name is allocated once.
|
|
3402
|
-
|
|
3476
|
+
// The table needs every effect's anchors before any of them compiles, so
|
|
3477
|
+
// this is the one place a source is read twice — compileEffect parses it
|
|
3478
|
+
// again for everything else. A malformed file yields no anchors here and
|
|
3479
|
+
// fails with its real diagnostics there, which is the right order: the
|
|
3480
|
+
// error names the line, not the table.
|
|
3481
|
+
const perEffectAnchors = requested.map((e) =>
|
|
3482
|
+
parseDirectives(e.wgsl).directives.anchors.slice(0, MAX_EFFECT_ANCHORS),
|
|
3483
|
+
)
|
|
3403
3484
|
const table = buildAnchorTable(perEffectAnchors, MAX_EFFECT_ANCHORS)
|
|
3404
3485
|
|
|
3405
3486
|
const results: EffectResult[] = []
|
|
@@ -3419,6 +3500,8 @@ export class Engine {
|
|
|
3419
3500
|
instances.push(built.instance)
|
|
3420
3501
|
results.push({
|
|
3421
3502
|
ok: true,
|
|
3503
|
+
params: built.instance.paramDecls,
|
|
3504
|
+
duration: built.instance.duration,
|
|
3422
3505
|
// Installed, and still with something to say — a directive that parsed
|
|
3423
3506
|
// but will never fire. Same channel as the dropped-anchor note below.
|
|
3424
3507
|
diagnostics: built.warnings,
|
|
@@ -3483,7 +3566,7 @@ export class Engine {
|
|
|
3483
3566
|
this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)")
|
|
3484
3567
|
this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)")
|
|
3485
3568
|
|
|
3486
|
-
// Nothing to promote any more:
|
|
3569
|
+
// Nothing to promote any more: `#fullres` is per effect, read into
|
|
3487
3570
|
// fieldLayer when the instance is built, and both target pairs exist for
|
|
3488
3571
|
// the life of the surface. What used to be a scene-wide decision made here
|
|
3489
3572
|
// is now each effect's own.
|
|
@@ -3501,14 +3584,18 @@ export class Engine {
|
|
|
3501
3584
|
const noMounts = { background: false, foreground: false }
|
|
3502
3585
|
if (wgsl === null) {
|
|
3503
3586
|
await this.setEffects(null)
|
|
3504
|
-
return { ok: true, diagnostics: [], mounts: noMounts }
|
|
3587
|
+
return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 }
|
|
3505
3588
|
}
|
|
3506
3589
|
const [result] = await this.setEffects([{ wgsl, params }])
|
|
3507
|
-
return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts }
|
|
3590
|
+
return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0 }
|
|
3508
3591
|
}
|
|
3509
3592
|
|
|
3510
3593
|
private async buildParticles(
|
|
3594
|
+
/** Already stripped of directives — see compileEffect. */
|
|
3511
3595
|
wgsl: string,
|
|
3596
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
3597
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
3598
|
+
d: EffectDirectives,
|
|
3512
3599
|
anchors: { bone: string; trail: boolean }[],
|
|
3513
3600
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
3514
3601
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
@@ -3517,8 +3604,8 @@ export class Engine {
|
|
|
3517
3604
|
): Promise<{ ok: true; state: EffectParticles } | { ok: false; diagnostics: string[] }> {
|
|
3518
3605
|
// No pragma means "some": an author who wrote the trio clearly wants
|
|
3519
3606
|
// particles, and failing over a missing comment would be pedantry.
|
|
3520
|
-
const count =
|
|
3521
|
-
const src = { wgsl, count, blend:
|
|
3607
|
+
const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES)
|
|
3608
|
+
const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom }
|
|
3522
3609
|
// Sparks want to spawn where a trail is, so the particle stages see the same
|
|
3523
3610
|
// cast buffer the trail draw reads.
|
|
3524
3611
|
const cast = {
|
|
@@ -3556,10 +3643,13 @@ export class Engine {
|
|
|
3556
3643
|
})
|
|
3557
3644
|
const uniform = this.device.createBuffer({
|
|
3558
3645
|
label: "particle uniforms",
|
|
3559
|
-
|
|
3646
|
+
// Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
|
|
3647
|
+
// The first was exactly full, and weight has to live in the same buffer
|
|
3648
|
+
// as the clock or a frame could draw one without the other.
|
|
3649
|
+
size: 32,
|
|
3560
3650
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
3561
3651
|
})
|
|
3562
|
-
const uniformBytes = new ArrayBuffer(
|
|
3652
|
+
const uniformBytes = new ArrayBuffer(32)
|
|
3563
3653
|
const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) }
|
|
3564
3654
|
|
|
3565
3655
|
// Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
|
|
@@ -3776,12 +3866,18 @@ export class Engine {
|
|
|
3776
3866
|
private emitLights(encoder: GPUCommandEncoder): void {
|
|
3777
3867
|
for (const e of this.effects) {
|
|
3778
3868
|
const l = e.lights
|
|
3869
|
+
// NOT skipped at weight 0, unlike every other mount. Each effect writes
|
|
3870
|
+
// its OWN slots in a shared buffer that is never cleared, so a skipped
|
|
3871
|
+
// dispatch leaves last frame's lights burning — the one place where not
|
|
3872
|
+
// running is the wrong answer. The shader zeroes them instead, and the
|
|
3873
|
+
// dispatch it costs is a single workgroup.
|
|
3779
3874
|
if (!l || l.data[2] === 0) continue
|
|
3780
3875
|
// The effect's OWN epoch — the same one its field, particle, ribbon and
|
|
3781
3876
|
// grid halves now read. This was briefly conditional, to match a field
|
|
3782
3877
|
// clock that was shared from the first installed effect; that clock is
|
|
3783
3878
|
// per effect now, so every mount in one file agrees by construction.
|
|
3784
3879
|
l.data[0] = this.sceneClock - e.epochScene
|
|
3880
|
+
l.data[3] = e.weight
|
|
3785
3881
|
this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer as ArrayBuffer)
|
|
3786
3882
|
const cp = encoder.beginComputePass({ label: "light emit" })
|
|
3787
3883
|
cp.setPipeline(l.pipeline)
|
|
@@ -3796,6 +3892,11 @@ export class Engine {
|
|
|
3796
3892
|
const p = e.particles
|
|
3797
3893
|
if (!p) continue
|
|
3798
3894
|
p.data[0] = this.sceneClock - e.epochScene
|
|
3895
|
+
// The SIMULATION runs at every weight, 0 included — only the draw stops.
|
|
3896
|
+
// A scheduled effect that froze while faded out would resume from the
|
|
3897
|
+
// state it left rather than the one it would have reached, so fading one
|
|
3898
|
+
// back in would rewind it.
|
|
3899
|
+
p.data[4] = e.weight
|
|
3799
3900
|
// Clamped: a backgrounded tab returns with a delta of whole seconds, and an
|
|
3800
3901
|
// unclamped step flings every particle out of the scene in one frame.
|
|
3801
3902
|
p.data[1] = Math.min(0.1, Math.max(0, deltaTime))
|
|
@@ -3814,7 +3915,7 @@ export class Engine {
|
|
|
3814
3915
|
private renderParticles(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
|
|
3815
3916
|
for (const e of this.effects) {
|
|
3816
3917
|
const p = e.particles
|
|
3817
|
-
if (!p) continue
|
|
3918
|
+
if (!p || e.weight === 0) continue
|
|
3818
3919
|
pass.setPipeline(p.render)
|
|
3819
3920
|
pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind)
|
|
3820
3921
|
pass.draw(6, p.count)
|
|
@@ -3829,7 +3930,11 @@ export class Engine {
|
|
|
3829
3930
|
* frame on the CPU.
|
|
3830
3931
|
*/
|
|
3831
3932
|
private async buildTrails(
|
|
3933
|
+
/** Already stripped of directives — see compileEffect. */
|
|
3832
3934
|
wgsl: string,
|
|
3935
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
3936
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
3937
|
+
d: EffectDirectives,
|
|
3833
3938
|
anchors: { bone: string; trail: boolean }[],
|
|
3834
3939
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
3835
3940
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
@@ -3845,7 +3950,7 @@ export class Engine {
|
|
|
3845
3950
|
// nothing before: ribbon i was read as anchor slot i.
|
|
3846
3951
|
const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0)
|
|
3847
3952
|
const slots = ribbonSlots.length
|
|
3848
|
-
const src = { wgsl, slots, ribbonSlots, blend:
|
|
3953
|
+
const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom }
|
|
3849
3954
|
const code = buildTrailShader(src, {
|
|
3850
3955
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
3851
3956
|
samples: TRAIL_SAMPLES,
|
|
@@ -3982,7 +4087,7 @@ export class Engine {
|
|
|
3982
4087
|
* Takes the pass rather than opening one: that IS the change.
|
|
3983
4088
|
*/
|
|
3984
4089
|
private drawTrails(pass: GPURenderPassEncoder, view: "camera" | "mirror"): void {
|
|
3985
|
-
const drawn = this.effects.filter((e) => e.trails)
|
|
4090
|
+
const drawn = this.effects.filter((e) => e.trails && e.weight > 0)
|
|
3986
4091
|
if (drawn.length === 0) return
|
|
3987
4092
|
for (const e of drawn) {
|
|
3988
4093
|
const t = e.trails!
|
|
@@ -4005,6 +4110,7 @@ export class Engine {
|
|
|
4005
4110
|
if (view === "camera") {
|
|
4006
4111
|
t.data[0] = this.sceneClock - e.epochScene
|
|
4007
4112
|
t.data[1] = live
|
|
4113
|
+
t.data[2] = e.weight
|
|
4008
4114
|
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer as ArrayBuffer)
|
|
4009
4115
|
}
|
|
4010
4116
|
pass.setPipeline(t.pipeline)
|
|
@@ -4017,14 +4123,31 @@ export class Engine {
|
|
|
4017
4123
|
* upsample. Runs the whole quad — uniform control flow, so effects may use
|
|
4018
4124
|
* derivatives freely, which the old inline path had to forbid. */
|
|
4019
4125
|
private renderFieldPass(encoder: GPUCommandEncoder): void {
|
|
4020
|
-
|
|
4021
|
-
|
|
4126
|
+
// TWO PREDICATES, deliberately, and they are not interchangeable.
|
|
4127
|
+
//
|
|
4128
|
+
// MOUNTED decides whether the pass runs, and it must agree exactly with
|
|
4129
|
+
// fieldPairUsed — that is what the composite's bind group was built against,
|
|
4130
|
+
// at install, and it is not rebuilt per frame. A pass skipped under a
|
|
4131
|
+
// binding that still points at its target leaves the last frame it drew
|
|
4132
|
+
// sitting there, so an effect faded to nothing would freeze on screen
|
|
4133
|
+
// instead of disappearing.
|
|
4134
|
+
//
|
|
4135
|
+
// DRAWN decides what is drawn into it, and this is where weight is worth
|
|
4136
|
+
// something: a field mount is a full-screen quad however little of the frame
|
|
4137
|
+
// it ends up touching, so an effect that is scheduled off would otherwise
|
|
4138
|
+
// shade every pixel to multiply it out to nothing. The pass still clears —
|
|
4139
|
+
// which is what makes the layer transparent rather than stale — and shades
|
|
4140
|
+
// nothing.
|
|
4141
|
+
const mounted = this.effects.filter((e) => e.fieldPipeline && e.fieldBindGroups)
|
|
4142
|
+
if (mounted.length === 0) return
|
|
4143
|
+
const drawn = mounted.filter((e) => e.weight > 0)
|
|
4022
4144
|
// Each effect's own clock, before the pass that reads it. Seconds since
|
|
4023
4145
|
// THIS effect was installed — so an effect added to a running scene starts
|
|
4024
4146
|
// at zero and can seed, rather than joining whatever the first one is up to.
|
|
4025
4147
|
for (const e of drawn) {
|
|
4026
4148
|
if (!e.fieldClock) continue
|
|
4027
4149
|
this.fieldClockScratch[0] = this.sceneClock - e.epochScene
|
|
4150
|
+
this.fieldClockScratch[1] = e.weight
|
|
4028
4151
|
this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer as ArrayBuffer)
|
|
4029
4152
|
}
|
|
4030
4153
|
// ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
|
|
@@ -4039,7 +4162,7 @@ export class Engine {
|
|
|
4039
4162
|
// (fieldLayerView). Clearing and storing an empty full-res rgba16f pair is
|
|
4040
4163
|
// two 16MB writes a frame to produce the transparent black the fallback
|
|
4041
4164
|
// already is. Most scenes leave the full-res pair empty, since an effect only
|
|
4042
|
-
// lands there by declaring
|
|
4165
|
+
// lands there by declaring #fullres.
|
|
4043
4166
|
let stamped = false
|
|
4044
4167
|
for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
|
|
4045
4168
|
const bg = this.fieldBgViews[i]
|
|
@@ -4052,7 +4175,7 @@ export class Engine {
|
|
|
4052
4175
|
{ view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
4053
4176
|
],
|
|
4054
4177
|
// One query pair is reserved for "field", and it goes to the first pair
|
|
4055
|
-
// that actually runs — full res when something declared
|
|
4178
|
+
// that actually runs — full res when something declared #fullres, half
|
|
4056
4179
|
// otherwise. Pinning it to i === 0 would have measured a pass that, now
|
|
4057
4180
|
// that empty pairs are skipped, usually does not happen.
|
|
4058
4181
|
timestampWrites: stamped ? undefined : this.stamps("field"),
|
|
@@ -4117,14 +4240,18 @@ export class Engine {
|
|
|
4117
4240
|
* zero, so seeding is just "if frame is 0, return the initial state".
|
|
4118
4241
|
*/
|
|
4119
4242
|
private async buildSim(
|
|
4243
|
+
/** Already stripped of directives — see compileEffect. */
|
|
4120
4244
|
wgsl: string,
|
|
4245
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
4246
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
4247
|
+
d: EffectDirectives,
|
|
4121
4248
|
anchors: { bone: string; trail: boolean }[],
|
|
4122
4249
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
4123
4250
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
4124
4251
|
* describes the effect that is still on screen. */
|
|
4125
4252
|
alias: number[],
|
|
4126
4253
|
): Promise<{ ok: true; state: EffectGrid } | { ok: false; diagnostics: string[] }> {
|
|
4127
|
-
const size =
|
|
4254
|
+
const size = Math.min(d.grid || 256, GRID_MAX)
|
|
4128
4255
|
const cast = {
|
|
4129
4256
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
4130
4257
|
samples: TRAIL_SAMPLES,
|
|
@@ -4263,10 +4390,19 @@ export class Engine {
|
|
|
4263
4390
|
return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false }
|
|
4264
4391
|
}
|
|
4265
4392
|
|
|
4266
|
-
/**
|
|
4267
|
-
*
|
|
4268
|
-
|
|
4269
|
-
|
|
4393
|
+
/**
|
|
4394
|
+
* Set one parameter on one INSTANCE.
|
|
4395
|
+
*
|
|
4396
|
+
* By index, because the scene holds a list and the same effect may appear in
|
|
4397
|
+
* it twice with different values — which is the whole point of an instance
|
|
4398
|
+
* and was impossible while this addressed `this.effect`, a singular left over
|
|
4399
|
+
* from when a scene could wear exactly one.
|
|
4400
|
+
*
|
|
4401
|
+
* A write, not a recompile: parameters live in their own uniform buffer, so
|
|
4402
|
+
* dragging a slider costs a 16-byte upload rather than a shader build.
|
|
4403
|
+
*/
|
|
4404
|
+
setEffectParam(index: number, name: string, value: EffectParamValue): void {
|
|
4405
|
+
const fx = this.effects[index]
|
|
4270
4406
|
if (!fx || !fx.paramsBuffer) return
|
|
4271
4407
|
const slot = fx.paramLayout.get(name)
|
|
4272
4408
|
if (!slot) return
|
|
@@ -4279,6 +4415,119 @@ export class Engine {
|
|
|
4279
4415
|
this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData)
|
|
4280
4416
|
}
|
|
4281
4417
|
|
|
4418
|
+
/**
|
|
4419
|
+
* How much of one instance is showing, 0..1.
|
|
4420
|
+
*
|
|
4421
|
+
* The third of the three things an instance has — parameters, weight, time —
|
|
4422
|
+
* and the one a scheduler drives. Weight is not a parameter: a parameter is
|
|
4423
|
+
* whatever the author decided to expose and means only what their source
|
|
4424
|
+
* makes it mean, while weight means the same thing for every effect ever
|
|
4425
|
+
* written, including one whose author never heard of it. That is why it is
|
|
4426
|
+
* applied by engine-generated code at each mount's output rather than handed
|
|
4427
|
+
* to the source as a uniform to respect.
|
|
4428
|
+
*
|
|
4429
|
+
* At 0 nothing is drawn: no field quad, no particle draw, no ribbon, no light
|
|
4430
|
+
* dispatch. A scheduled effect outside its window costs its simulation and
|
|
4431
|
+
* nothing else — and a particle effect keeps simulating on purpose, so that
|
|
4432
|
+
* fading one back in continues rather than rewinds.
|
|
4433
|
+
*
|
|
4434
|
+
* Instant, and free: a float in a uniform every mount already uploads once a
|
|
4435
|
+
* frame. Nothing recompiles, so this is safe to drive per frame from a
|
|
4436
|
+
* timeline.
|
|
4437
|
+
*/
|
|
4438
|
+
setEffectInfluence(index: number, influence: number): void {
|
|
4439
|
+
const fx = this.effects[index]
|
|
4440
|
+
if (!fx) return
|
|
4441
|
+
// Clamped rather than trusted: above 1 the field's own clamp would swallow
|
|
4442
|
+
// it while an additive particle would happily keep getting brighter, so the
|
|
4443
|
+
// same number would mean two things.
|
|
4444
|
+
fx.influence = Math.min(1, Math.max(0, influence))
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
getEffectInfluence(index: number): number {
|
|
4448
|
+
return this.effects[index]?.influence ?? 0
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4451
|
+
/**
|
|
4452
|
+
* Schedule one instance: when it is alive, and how it enters and leaves.
|
|
4453
|
+
*
|
|
4454
|
+
* Null is the unscheduled case — on for the whole scene, on the scene's own
|
|
4455
|
+
* clock — and is what an effect starts as.
|
|
4456
|
+
*
|
|
4457
|
+
* The engine evaluates this every frame rather than taking a weight from a
|
|
4458
|
+
* caller, because every loop that renders would otherwise have to remember to
|
|
4459
|
+
* drive it. The offline export loop already carries a scar about exactly that
|
|
4460
|
+
* shape of bug. Evaluating where the scene clock advances means playback and
|
|
4461
|
+
* export cannot disagree, and neither can forget.
|
|
4462
|
+
*
|
|
4463
|
+
* A caller that wants to drive an effect from something OTHER than the scene
|
|
4464
|
+
* clock — an animation's progress, a skill firing — leaves this null and
|
|
4465
|
+
* writes setEffectInfluence and setEffectTime itself, per frame. Both paths
|
|
4466
|
+
* exist on purpose; this one is what a timeline wants.
|
|
4467
|
+
*/
|
|
4468
|
+
setEffectSchedule(index: number, windows: readonly EffectWindow[] | null): void {
|
|
4469
|
+
const fx = this.effects[index]
|
|
4470
|
+
if (!fx) return
|
|
4471
|
+
fx.window = windows && windows.length ? windows : null
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4474
|
+
getEffectSchedule(index: number): readonly EffectWindow[] | null {
|
|
4475
|
+
return this.effects[index]?.window ?? null
|
|
4476
|
+
}
|
|
4477
|
+
|
|
4478
|
+
/**
|
|
4479
|
+
* Every scheduled effect, at the current scene clock.
|
|
4480
|
+
*
|
|
4481
|
+
* Called once a frame, BEFORE anything reads a weight or a clock. An effect
|
|
4482
|
+
* with no window keeps whatever a caller last set, which is what makes the
|
|
4483
|
+
* manual path above work — evaluating it would fight the caller for the field
|
|
4484
|
+
* every frame.
|
|
4485
|
+
*/
|
|
4486
|
+
private evaluateEffectSchedules(): void {
|
|
4487
|
+
// Read ONCE: it walks the cast, and every effect wants the same answer.
|
|
4488
|
+
const transport = this.transportTime()
|
|
4489
|
+
for (const fx of this.effects) {
|
|
4490
|
+
if (!fx.window || fx.window.length === 0) {
|
|
4491
|
+
fx.weight = fx.influence
|
|
4492
|
+
continue
|
|
4493
|
+
}
|
|
4494
|
+
const at = effectState(fx.window, fx.influence, transport)
|
|
4495
|
+
fx.weight = at.weight
|
|
4496
|
+
// Its own clock, expressed the way the mounts read it. Every mount
|
|
4497
|
+
// derives time from the epoch against sceneClock, so this one write moves
|
|
4498
|
+
// the field, the particles, the ribbons, lightEmit and the grid together
|
|
4499
|
+
// — and hands them the STRIP's local time while they keep running on the
|
|
4500
|
+
// smooth monotonic clock a particle integrator needs.
|
|
4501
|
+
fx.epochScene = this.sceneClock - at.time
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
/**
|
|
4506
|
+
* Move one instance's own clock to a given second.
|
|
4507
|
+
*
|
|
4508
|
+
* Everything an effect can animate is derived from its epoch — the field
|
|
4509
|
+
* clock, the particle and ribbon clocks, lightEmit's time argument, the grid's
|
|
4510
|
+
* frame counter — so moving the epoch moves all of them together and there is
|
|
4511
|
+
* no mount that can be left reading last frame's time.
|
|
4512
|
+
*
|
|
4513
|
+
* This is what lets an effect be SCHEDULED rather than merely switched on: an
|
|
4514
|
+
* instance that enters at bar 33 is handed a time that starts at zero there,
|
|
4515
|
+
* so it plays its own opening instead of joining whatever the scene clock had
|
|
4516
|
+
* reached. Feeding it the transport's time instead gives the other reading —
|
|
4517
|
+
* an effect that runs in lockstep with the music — and both are one call.
|
|
4518
|
+
*/
|
|
4519
|
+
setEffectTime(index: number, time: number): void {
|
|
4520
|
+
const fx = this.effects[index]
|
|
4521
|
+
if (!fx) return
|
|
4522
|
+
fx.epochScene = this.sceneClock - time
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
|
|
4526
|
+
getEffectTime(index: number): number {
|
|
4527
|
+
const fx = this.effects[index]
|
|
4528
|
+
return fx ? this.sceneClock - fx.epochScene : 0
|
|
4529
|
+
}
|
|
4530
|
+
|
|
4282
4531
|
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
4283
4532
|
/** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
|
|
4284
4533
|
* the scene pass only stores its depth buffer on frames the gather reads. */
|
|
@@ -4305,7 +4554,9 @@ export class Engine {
|
|
|
4305
4554
|
if (!this.camera) return null
|
|
4306
4555
|
const view = this.camera.getViewMatrix().values
|
|
4307
4556
|
for (const inst of this.modelInstances.values()) {
|
|
4308
|
-
|
|
4557
|
+
// Neither a stage nor a plane is a performer, so neither is a subject an
|
|
4558
|
+
// effect can follow.
|
|
4559
|
+
if (!inst.model.visible || inst.isStage || inst.isPlane) continue
|
|
4309
4560
|
const model = inst.model
|
|
4310
4561
|
const matrices = model.getWorldMatrices()
|
|
4311
4562
|
if (matrices.length === 0) continue
|
|
@@ -5938,7 +6189,7 @@ export class Engine {
|
|
|
5938
6189
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
5939
6190
|
})
|
|
5940
6191
|
|
|
5941
|
-
// The field layer — half resolution by default, full for
|
|
6192
|
+
// The field layer — half resolution by default, full for #fullres effects.
|
|
5942
6193
|
this.fieldFullW = width
|
|
5943
6194
|
this.fieldFullH = height
|
|
5944
6195
|
this.createFieldTargets()
|
|
@@ -6822,10 +7073,24 @@ export class Engine {
|
|
|
6822
7073
|
this.camera.setVmdDriven(false)
|
|
6823
7074
|
}
|
|
6824
7075
|
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
7076
|
+
/**
|
|
7077
|
+
* THE TRANSPORT'S CLOCK — where the scene is in its own playback.
|
|
7078
|
+
*
|
|
7079
|
+
* The first model with an active clip (playing or scrubbed), so a static stage
|
|
7080
|
+
* never freezes it at frame 0. Falls back to the first model with a clip, then
|
|
7081
|
+
* to 0 for an empty scene.
|
|
7082
|
+
*
|
|
7083
|
+
* NOT `sceneClock`, and the difference is the whole reason this has a name.
|
|
7084
|
+
* `sceneClock` only ever accumulates delta — it is how long the engine has
|
|
7085
|
+
* been running, it does not move when you scrub, and it does not stop when you
|
|
7086
|
+
* pause. Anything that should line up with what the transport shows has to
|
|
7087
|
+
* read THIS. An effect scheduled to frame 100 against sceneClock fires once,
|
|
7088
|
+
* a hundred frames after the page loaded, and never again.
|
|
7089
|
+
*
|
|
7090
|
+
* Deterministic offline: the export loop advances model animation by an exact
|
|
7091
|
+
* per-frame delta, so this reproduces frame for frame.
|
|
7092
|
+
*/
|
|
7093
|
+
private transportTime(): number {
|
|
6829
7094
|
let fallback: number | null = null
|
|
6830
7095
|
for (const inst of this.modelInstances.values()) {
|
|
6831
7096
|
// Stages are skipped outright. Scenery carries no motion, and it is added
|
|
@@ -6833,7 +7098,7 @@ export class Engine {
|
|
|
6833
7098
|
// is first in insertion order and was seeding this clock with its own
|
|
6834
7099
|
// permanent zero. In a scene with a stage, a camera VMD therefore sampled
|
|
6835
7100
|
// frame 0 forever and the shot never moved.
|
|
6836
|
-
if (inst.isStage) continue
|
|
7101
|
+
if (inst.isStage || inst.isPlane) continue
|
|
6837
7102
|
const p = inst.model.getAnimationProgress()
|
|
6838
7103
|
if (p.playing || p.paused) return p.current
|
|
6839
7104
|
// Otherwise the first cast member that actually HAS a clip: one still at
|
|
@@ -7223,7 +7488,7 @@ export class Engine {
|
|
|
7223
7488
|
pmxPath: string,
|
|
7224
7489
|
name?: string,
|
|
7225
7490
|
assetReader?: AssetReader,
|
|
7226
|
-
options?: { stage?: boolean },
|
|
7491
|
+
options?: { stage?: boolean; plane?: boolean; dynamic?: boolean },
|
|
7227
7492
|
): Promise<string> {
|
|
7228
7493
|
const requested = name ?? model.name
|
|
7229
7494
|
let key = requested
|
|
@@ -7234,7 +7499,15 @@ export class Engine {
|
|
|
7234
7499
|
const reader = assetReader ?? createFetchAssetReader()
|
|
7235
7500
|
const basePath = deriveBasePathFromPmxPath(pmxPath)
|
|
7236
7501
|
model.setAssetContext(reader, basePath)
|
|
7237
|
-
await this.setupModelInstance(
|
|
7502
|
+
await this.setupModelInstance(
|
|
7503
|
+
key,
|
|
7504
|
+
model,
|
|
7505
|
+
basePath,
|
|
7506
|
+
reader,
|
|
7507
|
+
options?.stage ?? false,
|
|
7508
|
+
options?.plane ?? false,
|
|
7509
|
+
options?.dynamic ?? false,
|
|
7510
|
+
)
|
|
7238
7511
|
return key
|
|
7239
7512
|
}
|
|
7240
7513
|
|
|
@@ -7267,6 +7540,191 @@ export class Engine {
|
|
|
7267
7540
|
return key
|
|
7268
7541
|
}
|
|
7269
7542
|
|
|
7543
|
+
/**
|
|
7544
|
+
* Put a picture in the scene as a flat card.
|
|
7545
|
+
*
|
|
7546
|
+
* The thing compositors arrange in a post tool's fake 3D space — Nuke calls
|
|
7547
|
+
* it a Card, After Effects a 3D layer, MMD 板ポリ — except the space here is
|
|
7548
|
+
* the real one. A card is occluded by anything in front of it, occludes what
|
|
7549
|
+
* is behind it, takes perspective when turned, and is caught by depth of
|
|
7550
|
+
* field like everything else, because it is ordinary geometry rather than a
|
|
7551
|
+
* layer composited afterwards.
|
|
7552
|
+
*
|
|
7553
|
+
* It is a MODEL, deliberately. Not a new kind of scene object with its own
|
|
7554
|
+
* list, its own persistence and its own selection: a card wants a position,
|
|
7555
|
+
* a rotation and a size, which is exactly what a model already has, and
|
|
7556
|
+
* everything built around models — the transform, the shadow settings, the
|
|
7557
|
+
* material editor, the asset bundle — works on it the day it exists. It is
|
|
7558
|
+
* not a STAGE, though: it skips the same machinery for the same reasons, and
|
|
7559
|
+
* leaves the floor alone. See ModelInstance.isPlane.
|
|
7560
|
+
*
|
|
7561
|
+
* @returns the model key, for setModelTransform and removeModel.
|
|
7562
|
+
*/
|
|
7563
|
+
async addPlane(options: {
|
|
7564
|
+
/** The picture's own bytes, exactly as uploaded. The name's extension picks
|
|
7565
|
+
* the decoder, so this never re-encodes anything. */
|
|
7566
|
+
image: ArrayBuffer
|
|
7567
|
+
/** File name — decides the decoder, names the model and keys its texture. */
|
|
7568
|
+
name: string
|
|
7569
|
+
/** World size of the card. The caller owns the aspect: it knows the
|
|
7570
|
+
* picture's own proportions, and a card is free to disagree with them. */
|
|
7571
|
+
width: number
|
|
7572
|
+
height: number
|
|
7573
|
+
transform?: Partial<ModelTransform>
|
|
7574
|
+
/** Drawn from behind as well. Off by default — a card turned away from the
|
|
7575
|
+
* camera vanishing is the same thing a sheet of paper does. */
|
|
7576
|
+
doubleSided?: boolean
|
|
7577
|
+
/** The picture will be replaced every frame (see setPlaneFrame). Allocates
|
|
7578
|
+
* the texture without a mip chain, which is what makes that affordable. */
|
|
7579
|
+
dynamic?: boolean
|
|
7580
|
+
}): Promise<string> {
|
|
7581
|
+
const { image, name, width, height } = options
|
|
7582
|
+
const hw = Math.max(width, 1e-4) / 2
|
|
7583
|
+
const hh = Math.max(height, 1e-4) / 2
|
|
7584
|
+
|
|
7585
|
+
// A quad on the XY plane, facing +Z, centred on its own origin — so a
|
|
7586
|
+
// rotation turns it about its middle and a position places its centre,
|
|
7587
|
+
// which is what a handle in the viewport implies.
|
|
7588
|
+
//
|
|
7589
|
+
// V IS FLIPPED, and this is the whole of it: a picture's rows run downward
|
|
7590
|
+
// from its top-left, a UV runs upward from the bottom-left, and a card that
|
|
7591
|
+
// renders its image upside down looks like a bug in everything else.
|
|
7592
|
+
// prettier-ignore
|
|
7593
|
+
const vertexData = new Float32Array([
|
|
7594
|
+
// x y z nx ny nz u v
|
|
7595
|
+
-hw, -hh, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0,
|
|
7596
|
+
hw, -hh, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
|
7597
|
+
hw, hh, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0,
|
|
7598
|
+
-hw, hh, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0,
|
|
7599
|
+
])
|
|
7600
|
+
// Two triangles, counter-clockwise seen from +Z. A second pair wound the
|
|
7601
|
+
// other way is how "visible from behind" is done here, rather than a
|
|
7602
|
+
// per-material cull flag the rest of the engine has no concept of.
|
|
7603
|
+
const indices = options.doubleSided ? [0, 1, 2, 0, 2, 3, 0, 2, 1, 0, 3, 2] : [0, 1, 2, 0, 2, 3]
|
|
7604
|
+
const indexData = new Uint32Array(indices)
|
|
7605
|
+
|
|
7606
|
+
// The texture table's one entry. The path is a key, not a location — the
|
|
7607
|
+
// reader below answers it from memory, so nothing is fetched and nothing is
|
|
7608
|
+
// written to disk.
|
|
7609
|
+
//
|
|
7610
|
+
// A PLAIN RELATIVE NAME under a plain directory, because the loader treats
|
|
7611
|
+
// this exactly as it treats a PMX's: it takes the model path's directory
|
|
7612
|
+
// and JOINS the texture entry onto it. A scheme-looking path went through
|
|
7613
|
+
// that as `plane://` + `plane://name` and matched nothing, so every card
|
|
7614
|
+
// came out with the untextured fallback. `plane/<name>` joins to
|
|
7615
|
+
// `plane/<name>` and stays unique per card, which the engine-wide texture
|
|
7616
|
+
// cache needs it to be.
|
|
7617
|
+
const texturePath = `plane/${name}`
|
|
7618
|
+
const material: Material = {
|
|
7619
|
+
name,
|
|
7620
|
+
diffuse: [1, 1, 1, 1],
|
|
7621
|
+
specular: [0, 0, 0],
|
|
7622
|
+
ambient: [0, 0, 0],
|
|
7623
|
+
shininess: 0,
|
|
7624
|
+
diffuseTextureIndex: 0,
|
|
7625
|
+
normalTextureIndex: -1,
|
|
7626
|
+
sphereTextureIndex: -1,
|
|
7627
|
+
sphereMode: 0,
|
|
7628
|
+
toonTextureIndex: -1,
|
|
7629
|
+
sharedToon: false,
|
|
7630
|
+
// 0 CARRIES TWO DECISIONS, both wanted, and both silent if changed.
|
|
7631
|
+
//
|
|
7632
|
+
// No inverted-hull outline (bit 0x10): a card is not a character, and a
|
|
7633
|
+
// black rim around a light leak is the opposite of what it is for.
|
|
7634
|
+
//
|
|
7635
|
+
// AND NO SHADOW (bit 0x04, which is what castsShadow reads). A card is
|
|
7636
|
+
// usually light or artwork rather than an object, and a rectangle of hard
|
|
7637
|
+
// shadow thrown across the stage by a gradient reads as the renderer
|
|
7638
|
+
// being broken. Set geometry that SHOULD cast one is the rarer case, and
|
|
7639
|
+
// it can say so.
|
|
7640
|
+
edgeFlag: 0,
|
|
7641
|
+
edgeColor: [0, 0, 0, 1],
|
|
7642
|
+
edgeSize: 0,
|
|
7643
|
+
vertexCount: indices.length,
|
|
7644
|
+
}
|
|
7645
|
+
|
|
7646
|
+
// ONE BONE, because Model requires one — it throws on an empty skeleton,
|
|
7647
|
+
// every vertex has to be skinned to something, and a card has nothing to
|
|
7648
|
+
// articulate. It is never posed; the model transform is what moves a card.
|
|
7649
|
+
const skeleton: Skeleton = {
|
|
7650
|
+
bones: [{ name: "全ての親", parentIndex: -1, bindTranslation: [0, 0, 0], children: [] }],
|
|
7651
|
+
inverseBindMatrices: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
7652
|
+
}
|
|
7653
|
+
const vertexCount = 4
|
|
7654
|
+
const joints = new Uint16Array(vertexCount * 4)
|
|
7655
|
+
const weights = new Uint8Array(vertexCount * 4)
|
|
7656
|
+
for (let i = 0; i < vertexCount; i++) weights[i * 4] = 255
|
|
7657
|
+
|
|
7658
|
+
const model = new Model(
|
|
7659
|
+
vertexData,
|
|
7660
|
+
indexData,
|
|
7661
|
+
[{ path: name, name }],
|
|
7662
|
+
[material],
|
|
7663
|
+
skeleton,
|
|
7664
|
+
{ joints, weights },
|
|
7665
|
+
{ morphs: [] },
|
|
7666
|
+
)
|
|
7667
|
+
|
|
7668
|
+
// ITS OWN PATH, not addStage's. A plane and a stage skip the same machinery
|
|
7669
|
+
// and mean different things, and routing one through the other is how the
|
|
7670
|
+
// ground came to be suppressed by adding a picture.
|
|
7671
|
+
// The picture answers from memory, whatever it is asked for: a card has
|
|
7672
|
+
// exactly ONE texture, so there is nothing to disambiguate and no way for a
|
|
7673
|
+
// path to be wrong. Matching the string instead is what silently produced
|
|
7674
|
+
// untextured cards, because the loader composes that string itself.
|
|
7675
|
+
const reader: AssetReader = { readBinary: async () => image }
|
|
7676
|
+
const key = await this.addModel(model, texturePath, name, reader, { plane: true, dynamic: options.dynamic })
|
|
7677
|
+
|
|
7678
|
+
// UNLIT, because a card is FOOTAGE and not a surface.
|
|
7679
|
+
//
|
|
7680
|
+
// Its pixels were finished somewhere else — a gradient painted in
|
|
7681
|
+
// Photoshop, a title, a rendered element — so its brightness is the artwork
|
|
7682
|
+
// rather than a response to anything. Shading it means the sun dimming one
|
|
7683
|
+
// side of a thing that has no sides, and the world colour tinting a picture
|
|
7684
|
+
// whose colour was the point. Left ungrouped it would take the neutral
|
|
7685
|
+
// Principled base, which is exactly that mistake.
|
|
7686
|
+
//
|
|
7687
|
+
// A group, not a hard-coded pipeline: a card used as SET geometry — a photo
|
|
7688
|
+
// of a wall, a poster standing in the room — genuinely does want the light,
|
|
7689
|
+
// and this is the same control every other material is changed through, so
|
|
7690
|
+
// that case is a graph swap rather than a feature request.
|
|
7691
|
+
await this.applyStyleGroups(key, [
|
|
7692
|
+
{ id: "plane", label: "Plane", materials: [name], graph: UNLIT_GRAPH, alphaMode: "hashed" },
|
|
7693
|
+
])
|
|
7694
|
+
|
|
7695
|
+
if (options.transform) this.setModelTransform(key, options.transform)
|
|
7696
|
+
// Kept so a moving card can push frames into it. The cache is keyed by the
|
|
7697
|
+
// texture's logical path, which is derived rather than stored anywhere the
|
|
7698
|
+
// caller can see — and deriving it twice is how the two would drift.
|
|
7699
|
+
const tex = this.textureCache.get(texturePath)
|
|
7700
|
+
if (tex) this.planeTextures.set(key, tex)
|
|
7701
|
+
return key
|
|
7702
|
+
}
|
|
7703
|
+
|
|
7704
|
+
/**
|
|
7705
|
+
* Replace what a card is showing, in place.
|
|
7706
|
+
*
|
|
7707
|
+
* For a moving card: a video element, a decoded frame, a canvas — anything
|
|
7708
|
+
* copyExternalImageToTexture accepts. Nothing is reallocated and no bind group
|
|
7709
|
+
* is rebuilt, so this is a per-frame call rather than a per-clip one; the
|
|
7710
|
+
* texture is written where it stands and the material keeps pointing at it.
|
|
7711
|
+
*
|
|
7712
|
+
* The frame must be the size the card was created at. A card is a fixed
|
|
7713
|
+
* rectangle of texels and resizing one mid-clip would mean rebuilding the
|
|
7714
|
+
* material behind it — so the caller allocates the card at its video's size
|
|
7715
|
+
* and this refuses anything else rather than stretching it silently.
|
|
7716
|
+
*/
|
|
7717
|
+
setPlaneFrame(id: string, source: GPUCopyExternalImageSource, width: number, height: number): boolean {
|
|
7718
|
+
const tex = this.planeTextures.get(id)
|
|
7719
|
+
if (!tex || !this.device) return false
|
|
7720
|
+
if (tex.width !== width || tex.height !== height) return false
|
|
7721
|
+
this.device.queue.copyExternalImageToTexture({ source }, { texture: tex }, [width, height])
|
|
7722
|
+
// A moving card is allocated with one level precisely so this is never
|
|
7723
|
+
// reached: rebuilding a mip pyramid per frame is a pass per level per card.
|
|
7724
|
+
if (tex.mipLevelCount > 1) this.generateMipmaps(tex, tex.mipLevelCount)
|
|
7725
|
+
return true
|
|
7726
|
+
}
|
|
7727
|
+
|
|
7270
7728
|
/** True while a stage is in the scene. Two things turn on it: the built-in
|
|
7271
7729
|
* ground plane must not draw, and the far shadow cascade has nothing to
|
|
7272
7730
|
* cover without one (see the cascade loop). */
|
|
@@ -7289,6 +7747,9 @@ export class Engine {
|
|
|
7289
7747
|
removeModel(name: string): void {
|
|
7290
7748
|
const inst = this.modelInstances.get(name)
|
|
7291
7749
|
if (!inst) return
|
|
7750
|
+
// Before the texture cache below frees it: a stale entry here would hand a
|
|
7751
|
+
// destroyed texture to the next setPlaneFrame.
|
|
7752
|
+
this.planeTextures.delete(name)
|
|
7292
7753
|
inst.model.stop()
|
|
7293
7754
|
for (const path of inst.textureCacheKeys) {
|
|
7294
7755
|
const tex = this.textureCache.get(path)
|
|
@@ -7563,10 +8024,10 @@ export class Engine {
|
|
|
7563
8024
|
// A stage never solves IK — nothing drives its chains — and skips the pose
|
|
7564
8025
|
// pass entirely while it is idle. Morph changes still come through, since
|
|
7565
8026
|
// that is the one thing a stage's controls do move.
|
|
7566
|
-
const stageIdle = inst.isStage && inst.model.isIdle()
|
|
8027
|
+
const stageIdle = (inst.isStage || inst.isPlane) && inst.model.isIdle()
|
|
7567
8028
|
let verticesChanged = false
|
|
7568
8029
|
if (!stageIdle) {
|
|
7569
|
-
verticesChanged = inst.model.update(deltaTime, inst.isStage ? false : this.ikEnabled)
|
|
8030
|
+
verticesChanged = inst.model.update(deltaTime, inst.isStage || inst.isPlane ? false : this.ikEnabled)
|
|
7570
8031
|
inst.skinMatricesDirty = true
|
|
7571
8032
|
}
|
|
7572
8033
|
animMs += performance.now() - tAnim
|
|
@@ -7989,6 +8450,8 @@ export class Engine {
|
|
|
7989
8450
|
|
|
7990
8451
|
/** Every shadow caster in one sphere: (x, y, z, radius). radius 0 = nothing
|
|
7991
8452
|
* casts, -1 = do not use (a rigid caster has no sphere). See updateCasterSphere. */
|
|
8453
|
+
/** A card's own texture, by model key — see setPlaneFrame. */
|
|
8454
|
+
private planeTextures = new Map<string, GPUTexture>()
|
|
7992
8455
|
private casterSphere = new Float32Array(4)
|
|
7993
8456
|
|
|
7994
8457
|
/** The ground's uniform block, kept so the caster sphere can be refreshed in
|
|
@@ -8560,6 +9023,8 @@ export class Engine {
|
|
|
8560
9023
|
basePath: string,
|
|
8561
9024
|
assetReader: AssetReader,
|
|
8562
9025
|
isStage = false,
|
|
9026
|
+
isPlane = false,
|
|
9027
|
+
dynamicTexture = false,
|
|
8563
9028
|
): Promise<void> {
|
|
8564
9029
|
const vertices = model.getVertices()
|
|
8565
9030
|
const skinning = model.getSkinning()
|
|
@@ -8620,7 +9085,7 @@ export class Engine {
|
|
|
8620
9085
|
// A stage never simulates, so its bodies are never built — constructing the
|
|
8621
9086
|
// solver for the heaviest mesh in the scene and dropping it afterwards was
|
|
8622
9087
|
// both wasted work and an invariant maintained in the wrong place.
|
|
8623
|
-
const physics = !isStage && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
|
|
9088
|
+
const physics = !isStage && !isPlane && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
|
|
8624
9089
|
// Which bones the simulation will overwrite, handed to the pose pipeline so
|
|
8625
9090
|
// the append (付与) pass can consume the simulated result instead of the
|
|
8626
9091
|
// animated one. Precomputed here, once, because the answer is topology —
|
|
@@ -8700,6 +9165,8 @@ export class Engine {
|
|
|
8700
9165
|
pickPerInstanceBindGroup,
|
|
8701
9166
|
pickDrawCalls: [],
|
|
8702
9167
|
isStage,
|
|
9168
|
+
isPlane,
|
|
9169
|
+
dynamicTexture,
|
|
8703
9170
|
// Seeded true: the bind pose has to reach the GPU once before any frame.
|
|
8704
9171
|
skinMatricesDirty: true,
|
|
8705
9172
|
hiddenMaterials: new Set(),
|
|
@@ -9200,7 +9667,21 @@ export class Engine {
|
|
|
9200
9667
|
bounds[4] += grow
|
|
9201
9668
|
bounds[5] += grow
|
|
9202
9669
|
|
|
9203
|
-
|
|
9670
|
+
// A CARD IS ALWAYS OPAQUE-PHASE, whatever its alpha says.
|
|
9671
|
+
//
|
|
9672
|
+
// The scene pass runs opaque -> ground -> transparent, and the ground
|
|
9673
|
+
// writes depth at every opacity (effects locate the floor by it). Every
|
|
9674
|
+
// card qualifies as transparent — a cutout has translucent texels, and a
|
|
9675
|
+
// video card starts from a blank sheet that is nothing but — so cards
|
|
9676
|
+
// drew after the ground and an INVISIBLE floor rejected them. Turning the
|
|
9677
|
+
// ground down for the shadow catcher made pictures disappear into it.
|
|
9678
|
+
//
|
|
9679
|
+
// Not a workaround: alphaMode "hashed" is alpha-to-coverage, which is the
|
|
9680
|
+
// transparency technique built for this phase, and addPlane already sets
|
|
9681
|
+
// it. The cost is dithering on a large soft gradient, where MSAA has four
|
|
9682
|
+
// coverage levels to spend — a cutout edge, which is what a card usually
|
|
9683
|
+
// has, resolves exactly.
|
|
9684
|
+
const type: DrawCallType = inst.isPlane ? "opaque" : isTransparent ? "transparent" : "opaque"
|
|
9204
9685
|
inst.drawCalls.push({
|
|
9205
9686
|
type,
|
|
9206
9687
|
count: indexCount,
|
|
@@ -9516,7 +9997,13 @@ export class Engine {
|
|
|
9516
9997
|
}
|
|
9517
9998
|
this.textureAlphaCache.set(cacheKey, alphaPlane)
|
|
9518
9999
|
|
|
9519
|
-
|
|
10000
|
+
// NO MIPS FOR A MOVING CARD. The chain would have to be rebuilt on every
|
|
10001
|
+
// frame written into it — a full pyramid of render passes per video plane
|
|
10002
|
+
// per frame, which is most of what a moving card was costing. Level 0 is
|
|
10003
|
+
// the only level a card in frame reads anyway; the price is aliasing on one
|
|
10004
|
+
// shrunk far into the distance, which is the case a video card is least
|
|
10005
|
+
// often in.
|
|
10006
|
+
const mipLevelCount = inst.dynamicTexture ? 1 : Math.floor(Math.log2(Math.max(width, height))) + 1
|
|
9520
10007
|
const texture = this.device.createTexture({
|
|
9521
10008
|
label: `texture: ${cacheKey}`,
|
|
9522
10009
|
size: [width, height],
|
|
@@ -10254,7 +10741,7 @@ export class Engine {
|
|
|
10254
10741
|
|
|
10255
10742
|
// Drive the shot from the camera VMD (synced to the animated model's clock).
|
|
10256
10743
|
if (this.camera.vmdDriven && this.cameraAnimation) {
|
|
10257
|
-
const pose = this.cameraAnimation.sample(this.
|
|
10744
|
+
const pose = this.cameraAnimation.sample(this.transportTime())
|
|
10258
10745
|
if (pose) this.camera.setVmdPose(pose)
|
|
10259
10746
|
}
|
|
10260
10747
|
|
|
@@ -10389,6 +10876,12 @@ export class Engine {
|
|
|
10389
10876
|
// uniforms this frame.
|
|
10390
10877
|
this.evaluateDissolveCycles()
|
|
10391
10878
|
this.evaluateParamTracks()
|
|
10879
|
+
// FIRST among the things that read an effect, because every one of them
|
|
10880
|
+
// reads what this writes: the sim's clock, the particle uniform's weight,
|
|
10881
|
+
// the light dispatch, the field draw. Evaluated here rather than by a
|
|
10882
|
+
// caller so that playback, the export loop and a warm-up pass cannot
|
|
10883
|
+
// disagree about when an effect is alive — none of them has to remember it.
|
|
10884
|
+
this.evaluateEffectSchedules()
|
|
10392
10885
|
this.stepSim(encoder, deltaTime)
|
|
10393
10886
|
this.stepParticles(encoder, deltaTime)
|
|
10394
10887
|
// Before the scene pass, which READS the slots this writes. Same buffer,
|
|
@@ -10450,7 +10943,7 @@ export class Engine {
|
|
|
10450
10943
|
this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView))
|
|
10451
10944
|
// Last in the pass: depth-tested against everything drawn above, so a
|
|
10452
10945
|
// particle behind the character is simply hidden, and still inside the HDR
|
|
10453
|
-
// target so an
|
|
10946
|
+
// target so an `#bloom` effect reaches the pyramid below.
|
|
10454
10947
|
this.renderParticles(pass, "camera")
|
|
10455
10948
|
// Ribbons, in the same pass and after the particles: both are additive
|
|
10456
10949
|
// light in HDR, and both reach the bloom pyramid because of it. This used
|