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/dist/engine.js
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 { MATERIAL_MORPH_MULTIPLY } from "./model";
|
|
5
|
+
import { Model, MATERIAL_MORPH_MULTIPLY } 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 } from "./shaders/anchor-table";
|
|
@@ -19,13 +19,14 @@ import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
|
|
|
19
19
|
import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
|
|
20
20
|
import { ID_DEBUG_SHADER_WGSL } from "./shaders/passes/id-debug";
|
|
21
21
|
import { paramChanged, sampleParamTrack } from "./param-track";
|
|
22
|
+
import { effectState } from "./effect-schedule";
|
|
22
23
|
import { SHADOW_CASCADES, buildShadowVP } from "./shadow-cascades";
|
|
23
24
|
import { REFLECTION_DEBUG_WGSL, buildMirrorCamera } from "./reflection";
|
|
24
25
|
import { packHalf } from "./hdr";
|
|
25
26
|
import { evalIrradianceSH, projectIrradianceSH } from "./ibl";
|
|
26
27
|
import { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, LYRICS_FLOATS, packLyrics } from "./shaders/lyrics-api";
|
|
27
28
|
import { sceneTargets as sceneTargetsFor, sceneColorFormats, setMrtIds, mrtIdsEnabled, SCENE_ID_FORMAT, } from "./shaders/passes/scene-contract";
|
|
28
|
-
import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit,
|
|
29
|
+
import { LIGHT_HEADER, LIGHT_STRIDE, LIGHTS_FLOATS, MAX_LIGHTS, buildLightEmitShader, hasLightEmit, } from "./shaders/lights";
|
|
29
30
|
import { groundShaderWgsl, GROUND_NOISE_BAKE_WGSL, GROUND_NOISE_SIZE } from "./shaders/passes/ground";
|
|
30
31
|
import { outlineShaderWgsl } from "./shaders/passes/outline";
|
|
31
32
|
import { transparentDepthPrepassWgsl } from "./shaders/passes/depth-prepass";
|
|
@@ -33,14 +34,16 @@ import { SELECTION_MASK_SHADER_WGSL, SELECTION_EDGE_SHADER_WGSL } from "./shader
|
|
|
33
34
|
import { GIZMO_SHADER_WGSL } from "./shaders/passes/gizmo";
|
|
34
35
|
import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
|
|
35
36
|
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut";
|
|
36
|
-
import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader,
|
|
37
|
-
import { buildParticleComputeShader, buildParticleRenderShader,
|
|
38
|
-
import { SIM_FORMAT, GRID_MAX, buildSimShader,
|
|
37
|
+
import { buildCompositeShader, EFFECT_SCENE_API, buildFieldShader, EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES, } from "./shaders/passes/composite";
|
|
38
|
+
import { buildParticleComputeShader, buildParticleRenderShader, particleEntryPoints, PARTICLE_STRIDE, } from "./shaders/passes/particles";
|
|
39
|
+
import { SIM_FORMAT, GRID_MAX, buildSimShader, gridEntryPoint, } from "./shaders/passes/grid";
|
|
39
40
|
import { buildTrailShader, trailEntryPoints, TRAIL_SUBDIVISIONS } from "./shaders/passes/trails";
|
|
40
41
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
|
|
41
42
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
|
|
42
43
|
import { compileGraph } from "./graph/compile";
|
|
43
44
|
import { DEFAULT_GRAPH } from "./graph/presets/default";
|
|
45
|
+
import { parseDirectives, stripDirectives } from "./shaders/directives";
|
|
46
|
+
import { UNLIT_GRAPH } from "./graph/presets/unlit";
|
|
44
47
|
import { FACE_GRAPH } from "./graph/presets/face";
|
|
45
48
|
import { HAIR_GRAPH } from "./graph/presets/hair";
|
|
46
49
|
import { BODY_GRAPH } from "./graph/presets/body";
|
|
@@ -596,13 +599,13 @@ const FIELD_LAYER_BLEND = {
|
|
|
596
599
|
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
597
600
|
};
|
|
598
601
|
/**
|
|
599
|
-
*
|
|
602
|
+
* `#layer additive` — for LIGHT rather than matter.
|
|
600
603
|
*
|
|
601
604
|
* Alpha-over is right for anything with mass: smoke, fog, a backdrop. It is
|
|
602
605
|
* wrong for a glow, and visibly so the moment two of them cross — the later
|
|
603
606
|
* bolt occludes the earlier one in proportion to its own brightness, when what
|
|
604
607
|
* light does is get brighter. Unity and Unreal both ship exactly this split,
|
|
605
|
-
* and the particle path here already has it as
|
|
608
|
+
* and the particle path here already has it as `#blend additive`.
|
|
606
609
|
*
|
|
607
610
|
* Colour still scales by the author's alpha, so alpha keeps meaning "how much
|
|
608
611
|
* of this is here" and an effect fades out the way it always did. What changes
|
|
@@ -983,6 +986,8 @@ export class Engine {
|
|
|
983
986
|
this.framePhysicsMsRaw = 0;
|
|
984
987
|
/** Every shadow caster in one sphere: (x, y, z, radius). radius 0 = nothing
|
|
985
988
|
* casts, -1 = do not use (a rigid caster has no sphere). See updateCasterSphere. */
|
|
989
|
+
/** A card's own texture, by model key — see setPlaneFrame. */
|
|
990
|
+
this.planeTextures = new Map();
|
|
986
991
|
this.casterSphere = new Float32Array(4);
|
|
987
992
|
/** The ground's uniform block, kept so the caster sphere can be refreshed in
|
|
988
993
|
* it every frame rather than rebuilding the buffer (addGround allocates). */
|
|
@@ -2075,7 +2080,9 @@ export class Engine {
|
|
|
2075
2080
|
* is KEPT and diagnostics are returned with line numbers relative to the
|
|
2076
2081
|
* user's WGSL. Pass null to remove the effect.
|
|
2077
2082
|
*/
|
|
2078
|
-
async compileEffect(
|
|
2083
|
+
async compileEffect(
|
|
2084
|
+
/** The author's file, directives included — parsed here and nowhere else. */
|
|
2085
|
+
authored, params,
|
|
2079
2086
|
/** This effect's own declarations, already parsed by the caller — which had
|
|
2080
2087
|
* to read them anyway to build the scene table. */
|
|
2081
2088
|
anchors,
|
|
@@ -2083,7 +2090,22 @@ export class Engine {
|
|
|
2083
2090
|
alias) {
|
|
2084
2091
|
const noMounts = { background: false, foreground: false };
|
|
2085
2092
|
if (!this.device)
|
|
2086
|
-
return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts };
|
|
2093
|
+
return { ok: false, diagnostics: ["setEffect requires init() to have run"], mounts: noMounts, params: [], duration: 0 };
|
|
2094
|
+
// WHAT THE FILE DECLARES, read once. Everything below takes it from `d`
|
|
2095
|
+
// rather than running a regex of its own — eight parsers over one file was
|
|
2096
|
+
// eight chances to disagree about what it said, and they did.
|
|
2097
|
+
//
|
|
2098
|
+
// An unrecognised or malformed directive is an ERROR. `#` is not WGSL
|
|
2099
|
+
// syntax, so a line starting with one is unambiguously ours and there is
|
|
2100
|
+
// nothing to be lenient about; the old spelling lived in comments, where a
|
|
2101
|
+
// typo was indistinguishable from prose and could only ever be warned about.
|
|
2102
|
+
const parsed = parseDirectives(authored);
|
|
2103
|
+
if (parsed.errors.length)
|
|
2104
|
+
return { ok: false, diagnostics: parsed.errors, mounts: noMounts, params: [], duration: 0 };
|
|
2105
|
+
const d = parsed.directives;
|
|
2106
|
+
// The compiler sees the file with its directive lines BLANKED, so every
|
|
2107
|
+
// diagnostic below still names the line the author is looking at.
|
|
2108
|
+
const wgsl = stripDirectives(authored);
|
|
2087
2109
|
// ── Which mounts did the author ask for? A declaration, not a setting: the
|
|
2088
2110
|
// entry points present in the source are the ones compiled in. Matching the
|
|
2089
2111
|
// `fn` keyword is enough to be safe against a `foreground` LOCAL or a call
|
|
@@ -2099,14 +2121,10 @@ export class Engine {
|
|
|
2099
2121
|
const te = trailEntryPoints(wgsl);
|
|
2100
2122
|
const wantsTrails = te.width || te.shade;
|
|
2101
2123
|
if (wantsTrails && !(te.width && te.shade)) {
|
|
2102
|
-
return {
|
|
2103
|
-
ok: false,
|
|
2104
|
-
diagnostics: [
|
|
2124
|
+
return { ok: false, diagnostics: [
|
|
2105
2125
|
`a ribbon effect needs both fn trailWidth(u: f32, age: f32) -> f32 and ` +
|
|
2106
2126
|
`fn trailShade(u: f32, v: f32, age: f32, weight: f32, slot: i32) -> vec4f`,
|
|
2107
|
-
],
|
|
2108
|
-
mounts: noMounts,
|
|
2109
|
-
};
|
|
2127
|
+
], mounts: noMounts, params: [], duration: 0 };
|
|
2110
2128
|
}
|
|
2111
2129
|
if (wantsParticles && !(pe.init && pe.step && pe.shade)) {
|
|
2112
2130
|
const missing = [
|
|
@@ -2114,7 +2132,7 @@ export class Engine {
|
|
|
2114
2132
|
pe.step ? null : "fn particleStep(p: Particle, dt: f32) -> Particle",
|
|
2115
2133
|
pe.shade ? null : "fn particleShade(p: Particle, uv: vec2f) -> vec4f",
|
|
2116
2134
|
].filter(Boolean);
|
|
2117
|
-
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts };
|
|
2135
|
+
return { ok: false, diagnostics: [`a particle effect also needs ${missing.join(" and ")}`], mounts: noMounts, params: [], duration: 0 };
|
|
2118
2136
|
}
|
|
2119
2137
|
// One file, one kind — for now.
|
|
2120
2138
|
//
|
|
@@ -2129,35 +2147,27 @@ export class Engine {
|
|
|
2129
2147
|
// says so plainly instead of failing with "unresolved type Particle" from a
|
|
2130
2148
|
// pass they did not know they were compiling into.
|
|
2131
2149
|
if ((wantsParticles || wantsTrails) && (hasBackground || hasForeground)) {
|
|
2132
|
-
return {
|
|
2133
|
-
ok: false,
|
|
2134
|
-
diagnostics: [
|
|
2150
|
+
return { ok: false, diagnostics: [
|
|
2135
2151
|
"an effect declares field mounts (background/foreground) or particles, not both — " +
|
|
2136
2152
|
"split them into two effects",
|
|
2137
|
-
],
|
|
2138
|
-
mounts: noMounts,
|
|
2139
|
-
};
|
|
2153
|
+
], mounts: noMounts, params: [], duration: 0 };
|
|
2140
2154
|
}
|
|
2141
2155
|
// lightEmit counts as a mount on its own: a pure lighting rig draws nothing
|
|
2142
2156
|
// and is still an effect — it is how a scene gets stage lights without also
|
|
2143
2157
|
// getting geometry it did not ask for.
|
|
2144
2158
|
if (!hasBackground && !hasForeground && !wantsParticles && !wantsTrails && !hasLightEmit(wgsl)) {
|
|
2145
|
-
return {
|
|
2146
|
-
ok: false,
|
|
2147
|
-
diagnostics: [
|
|
2159
|
+
return { ok: false, diagnostics: [
|
|
2148
2160
|
"an effect must define fn background(ray: vec3f, uv: vec2f, time: f32) -> vec4f, " +
|
|
2149
2161
|
"fn foreground(ray: vec3f, uv: vec2f, time: f32, depth: f32) -> vec4f, " +
|
|
2150
2162
|
"the particle trio (particleInit/particleStep/particleShade), " +
|
|
2151
2163
|
"the ribbon pair (trailWidth/trailShade), " +
|
|
2152
|
-
"or fn lightEmit(i: u32) -> RzLight with
|
|
2153
|
-
],
|
|
2154
|
-
mounts: noMounts,
|
|
2155
|
-
};
|
|
2164
|
+
"or fn lightEmit(i: u32) -> RzLight with #lights <n>",
|
|
2165
|
+
], mounts: noMounts, params: [], duration: 0 };
|
|
2156
2166
|
}
|
|
2157
2167
|
const mounts = { background: hasBackground, foreground: hasForeground };
|
|
2158
2168
|
// ── Directives only some mounts honour ──
|
|
2159
2169
|
//
|
|
2160
|
-
//
|
|
2170
|
+
// #bloom sets the aux mask, and only the particle and ribbon modules write
|
|
2161
2171
|
// that mask: they draw inside the scene pass, in HDR, while the bloom
|
|
2162
2172
|
// pyramid can still see them. A field effect composites in DISPLAY space
|
|
2163
2173
|
// after tone mapping, so there is nothing left to pick it up and the
|
|
@@ -2170,8 +2180,8 @@ export class Engine {
|
|
|
2170
2180
|
// pinning an effect that declares this has to keep installing; saying so is
|
|
2171
2181
|
// all that was ever missing.
|
|
2172
2182
|
const warnings = [];
|
|
2173
|
-
if (
|
|
2174
|
-
warnings.push("
|
|
2183
|
+
if (d.bloom && !wantsParticles && !wantsTrails) {
|
|
2184
|
+
warnings.push("#bloom does nothing here. A field effect (background/foreground) composites after tone " +
|
|
2175
2185
|
"mapping, past the bloom pyramid — the directive applies to particles and ribbons, which draw " +
|
|
2176
2186
|
"in HDR inside the scene pass. Make the effect's own falloff brighter instead.");
|
|
2177
2187
|
}
|
|
@@ -2191,7 +2201,7 @@ export class Engine {
|
|
|
2191
2201
|
let cursor = 0;
|
|
2192
2202
|
for (const [name, value] of entries) {
|
|
2193
2203
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
2194
|
-
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts };
|
|
2204
|
+
return { ok: false, diagnostics: [`invalid param name "${name}" (must be a WGSL identifier)`], mounts, params: d.params, duration: d.duration };
|
|
2195
2205
|
}
|
|
2196
2206
|
const isVec = typeof value !== "number";
|
|
2197
2207
|
const align = isVec ? 16 : 4;
|
|
@@ -2220,7 +2230,7 @@ export class Engine {
|
|
|
2220
2230
|
// module (buildFieldShader), so a bad effect can no longer produce errors at
|
|
2221
2231
|
// line numbers in a shader the author never wrote — and installing one no
|
|
2222
2232
|
// longer recompiles the composite's tone-mapping half at all.
|
|
2223
|
-
const gridSize = gridEntryPoint(wgsl) ?
|
|
2233
|
+
const gridSize = gridEntryPoint(wgsl) ? Math.min(d.grid || 256, GRID_MAX) : 0;
|
|
2224
2234
|
// `alias` goes in: a field effect reads bones through _rzSlot exactly as a
|
|
2225
2235
|
// particle one does, and it was the only module never handed the mapping.
|
|
2226
2236
|
const fieldEffect = hasBackground || hasForeground ? { wgsl, paramsDecl, hasBackground, hasForeground, gridSize, alias, trailCount: anchors.filter((a) => a.trail).length } : null;
|
|
@@ -2229,10 +2239,10 @@ export class Engine {
|
|
|
2229
2239
|
const module = this.device.createShaderModule({ label: "composite shader (effect)", code: source });
|
|
2230
2240
|
const scopeErr = await this.device.popErrorScope();
|
|
2231
2241
|
if (scopeErr)
|
|
2232
|
-
return { ok: false, diagnostics: [scopeErr.message], mounts };
|
|
2242
|
+
return { ok: false, diagnostics: [scopeErr.message], mounts, params: d.params, duration: d.duration };
|
|
2233
2243
|
// Declared like every other mount property: by what the source says, not by
|
|
2234
2244
|
// a setting somewhere else that an author cannot see from the file.
|
|
2235
|
-
const layerBlend =
|
|
2245
|
+
const layerBlend = d.additiveLayer
|
|
2236
2246
|
? FIELD_LAYER_BLEND_ADDITIVE
|
|
2237
2247
|
: FIELD_LAYER_BLEND;
|
|
2238
2248
|
let fieldPipeline = null;
|
|
@@ -2249,7 +2259,7 @@ export class Engine {
|
|
|
2249
2259
|
if (diagnostics.length === 0 && fieldScopeErr)
|
|
2250
2260
|
diagnostics.push(fieldScopeErr.message);
|
|
2251
2261
|
if (diagnostics.length > 0)
|
|
2252
|
-
return { ok: false, diagnostics, mounts };
|
|
2262
|
+
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration };
|
|
2253
2263
|
try {
|
|
2254
2264
|
fieldPipeline = await this.device.createRenderPipelineAsync({
|
|
2255
2265
|
label: "field layer pipeline",
|
|
@@ -2276,7 +2286,7 @@ export class Engine {
|
|
|
2276
2286
|
});
|
|
2277
2287
|
}
|
|
2278
2288
|
catch (e) {
|
|
2279
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
|
|
2289
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration };
|
|
2280
2290
|
}
|
|
2281
2291
|
}
|
|
2282
2292
|
let identity;
|
|
@@ -2300,7 +2310,7 @@ export class Engine {
|
|
|
2300
2310
|
]);
|
|
2301
2311
|
}
|
|
2302
2312
|
catch (e) {
|
|
2303
|
-
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts };
|
|
2313
|
+
return { ok: false, diagnostics: [e instanceof Error ? e.message : String(e)], mounts, params: d.params, duration: d.duration };
|
|
2304
2314
|
}
|
|
2305
2315
|
// Built BEFORE the swap: a particle stage that fails to compile has to leave
|
|
2306
2316
|
// the previously installed effect running, exactly as a bad composite does.
|
|
@@ -2315,18 +2325,18 @@ export class Engine {
|
|
|
2315
2325
|
grid?.textures[1].destroy();
|
|
2316
2326
|
grid?.uniform.destroy();
|
|
2317
2327
|
trails?.uniform.destroy();
|
|
2318
|
-
return { ok: false, diagnostics, mounts };
|
|
2328
|
+
return { ok: false, diagnostics, mounts, params: d.params, duration: d.duration };
|
|
2319
2329
|
};
|
|
2320
2330
|
let particles = null;
|
|
2321
2331
|
if (wantsParticles) {
|
|
2322
|
-
const built = await this.buildParticles(wgsl, anchors, alias);
|
|
2332
|
+
const built = await this.buildParticles(wgsl, d, anchors, alias);
|
|
2323
2333
|
if (!built.ok)
|
|
2324
2334
|
return abandon(built.diagnostics);
|
|
2325
2335
|
particles = built.state;
|
|
2326
2336
|
}
|
|
2327
2337
|
let grid = null;
|
|
2328
2338
|
if (gridEntryPoint(wgsl)) {
|
|
2329
|
-
const built = await this.buildSim(wgsl, anchors, alias);
|
|
2339
|
+
const built = await this.buildSim(wgsl, d, anchors, alias);
|
|
2330
2340
|
if (!built.ok)
|
|
2331
2341
|
return abandon(built.diagnostics);
|
|
2332
2342
|
grid = built.state;
|
|
@@ -2337,13 +2347,9 @@ export class Engine {
|
|
|
2337
2347
|
// bone recorded without one would read zeroes and paint a line to the origin.
|
|
2338
2348
|
const trailSlots = anchors.filter((a) => a.trail).length;
|
|
2339
2349
|
if (trailSlots === 0) {
|
|
2340
|
-
return {
|
|
2341
|
-
ok: false,
|
|
2342
|
-
diagnostics: ["a ribbon effect needs at least one // @anchor <bone> trail"],
|
|
2343
|
-
mounts,
|
|
2344
|
-
};
|
|
2350
|
+
return { ok: false, diagnostics: ["a ribbon effect needs at least one #anchor <bone> trail"], mounts, params: d.params, duration: d.duration };
|
|
2345
2351
|
}
|
|
2346
|
-
const built = await this.buildTrails(wgsl, anchors, alias);
|
|
2352
|
+
const built = await this.buildTrails(wgsl, d, anchors, alias);
|
|
2347
2353
|
if (!built.ok)
|
|
2348
2354
|
return abandon(built.diagnostics);
|
|
2349
2355
|
trails = built.state;
|
|
@@ -2356,13 +2362,13 @@ export class Engine {
|
|
|
2356
2362
|
// count is a function nothing calls. Either alone is a silent blank, which
|
|
2357
2363
|
// is the worst way for an effect to fail.
|
|
2358
2364
|
let lights = null;
|
|
2359
|
-
const declaredLights =
|
|
2365
|
+
const declaredLights = Math.min(d.lights, MAX_LIGHTS);
|
|
2360
2366
|
const emits = hasLightEmit(wgsl);
|
|
2361
2367
|
if (declaredLights > 0 !== emits) {
|
|
2362
2368
|
return abandon([
|
|
2363
2369
|
emits
|
|
2364
|
-
? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with
|
|
2365
|
-
: "
|
|
2370
|
+
? "an effect defining fn lightEmit(i: u32) -> RzLight must also declare how many with #lights <n>"
|
|
2371
|
+
: "#lights <n> needs fn lightEmit(i: u32) -> RzLight to fill those slots",
|
|
2366
2372
|
]);
|
|
2367
2373
|
}
|
|
2368
2374
|
if (declaredLights > 0) {
|
|
@@ -2390,6 +2396,8 @@ export class Engine {
|
|
|
2390
2396
|
}
|
|
2391
2397
|
const instance = {
|
|
2392
2398
|
wgsl,
|
|
2399
|
+
paramDecls: d.params,
|
|
2400
|
+
duration: d.duration,
|
|
2393
2401
|
paramLayout: layout,
|
|
2394
2402
|
paramsBuffer,
|
|
2395
2403
|
paramsData,
|
|
@@ -2404,9 +2412,30 @@ export class Engine {
|
|
|
2404
2412
|
// The effect's own clock starts now. Per effect so that one installed
|
|
2405
2413
|
// later still gets a frame where rzGridFrame() is 0 and can seed.
|
|
2406
2414
|
epochScene: this.sceneClock,
|
|
2415
|
+
// Fully on, unscheduled. An effect that is installed is showing;
|
|
2416
|
+
// scheduling it is something a caller does afterwards, and an install
|
|
2417
|
+
// that silently began at zero would look like a compile that failed.
|
|
2418
|
+
influence: 1,
|
|
2419
|
+
window: null,
|
|
2420
|
+
weight: 1,
|
|
2407
2421
|
// Its OWN resolution, no longer the scene's: an effect that never asked
|
|
2408
2422
|
// for full res is not promoted because a neighbour did.
|
|
2409
|
-
|
|
2423
|
+
// FULL RESOLUTION UNLESS TOLD OTHERWISE.
|
|
2424
|
+
//
|
|
2425
|
+
// It was the other way round, and the default was the bug. An author
|
|
2426
|
+
// who has never heard of the flag writes an effect with an edge in it
|
|
2427
|
+
// and gets a soft one — nothing fails, nothing warns, because nothing
|
|
2428
|
+
// was declared to fail. Three shipped effects DID declare it and were
|
|
2429
|
+
// half-res anyway on a parsing technicality, which is the same bug
|
|
2430
|
+
// wearing a different hat: the safe answer has to be the one you get
|
|
2431
|
+
// for saying nothing.
|
|
2432
|
+
//
|
|
2433
|
+
// The cost is real and is why the half layer stays: `#halfres` is worth
|
|
2434
|
+
// about 3.7x on a full-screen effect (Footprints, measured, 1.2ms
|
|
2435
|
+
// against 4.5ms). It is the right call for a soft additive glow, which
|
|
2436
|
+
// upsamples invisibly — and it is now a claim an author makes about
|
|
2437
|
+
// their own effect rather than a fate that befalls one.
|
|
2438
|
+
fieldLayer: d.fieldLayer,
|
|
2410
2439
|
fieldPipeline,
|
|
2411
2440
|
fieldClock,
|
|
2412
2441
|
// Filled by rebuildFieldBindGroup below, which needs the instance to
|
|
@@ -2446,7 +2475,7 @@ export class Engine {
|
|
|
2446
2475
|
async setEffects(list) {
|
|
2447
2476
|
const noMounts = { background: false, foreground: false };
|
|
2448
2477
|
if (!this.device)
|
|
2449
|
-
return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts }];
|
|
2478
|
+
return [{ ok: false, diagnostics: ["setEffects requires init() to have run"], mounts: noMounts, params: [], duration: 0 }];
|
|
2450
2479
|
const requested = list ?? [];
|
|
2451
2480
|
if (requested.length === 0) {
|
|
2452
2481
|
for (const e of this.effects) {
|
|
@@ -2470,7 +2499,12 @@ export class Engine {
|
|
|
2470
2499
|
}
|
|
2471
2500
|
// One table for the whole scene, built before anything compiles: an effect's
|
|
2472
2501
|
// alias is its row, and a bone two effects both name is allocated once.
|
|
2473
|
-
|
|
2502
|
+
// The table needs every effect's anchors before any of them compiles, so
|
|
2503
|
+
// this is the one place a source is read twice — compileEffect parses it
|
|
2504
|
+
// again for everything else. A malformed file yields no anchors here and
|
|
2505
|
+
// fails with its real diagnostics there, which is the right order: the
|
|
2506
|
+
// error names the line, not the table.
|
|
2507
|
+
const perEffectAnchors = requested.map((e) => parseDirectives(e.wgsl).directives.anchors.slice(0, MAX_EFFECT_ANCHORS));
|
|
2474
2508
|
const table = buildAnchorTable(perEffectAnchors, MAX_EFFECT_ANCHORS);
|
|
2475
2509
|
const results = [];
|
|
2476
2510
|
const instances = [];
|
|
@@ -2484,6 +2518,8 @@ export class Engine {
|
|
|
2484
2518
|
instances.push(built.instance);
|
|
2485
2519
|
results.push({
|
|
2486
2520
|
ok: true,
|
|
2521
|
+
params: built.instance.paramDecls,
|
|
2522
|
+
duration: built.instance.duration,
|
|
2487
2523
|
// Installed, and still with something to say — a directive that parsed
|
|
2488
2524
|
// but will never fire. Same channel as the dropped-anchor note below.
|
|
2489
2525
|
diagnostics: built.warnings,
|
|
@@ -2543,7 +2579,7 @@ export class Engine {
|
|
|
2543
2579
|
});
|
|
2544
2580
|
this.compositePipelineIdentity = this.makeCompositePipeline(compositeModule, false, "composite pipeline (gamma=1)");
|
|
2545
2581
|
this.compositePipelineGamma = this.makeCompositePipeline(compositeModule, true, "composite pipeline (gamma!=1)");
|
|
2546
|
-
// Nothing to promote any more:
|
|
2582
|
+
// Nothing to promote any more: `#fullres` is per effect, read into
|
|
2547
2583
|
// fieldLayer when the instance is built, and both target pairs exist for
|
|
2548
2584
|
// the life of the surface. What used to be a scene-wide decision made here
|
|
2549
2585
|
// is now each effect's own.
|
|
@@ -2560,20 +2596,25 @@ export class Engine {
|
|
|
2560
2596
|
const noMounts = { background: false, foreground: false };
|
|
2561
2597
|
if (wgsl === null) {
|
|
2562
2598
|
await this.setEffects(null);
|
|
2563
|
-
return { ok: true, diagnostics: [], mounts: noMounts };
|
|
2599
|
+
return { ok: true, diagnostics: [], mounts: noMounts, params: [], duration: 0 };
|
|
2564
2600
|
}
|
|
2565
2601
|
const [result] = await this.setEffects([{ wgsl, params }]);
|
|
2566
|
-
return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts };
|
|
2567
|
-
}
|
|
2568
|
-
async buildParticles(
|
|
2602
|
+
return result ?? { ok: false, diagnostics: ["effect failed to install"], mounts: noMounts, params: [], duration: 0 };
|
|
2603
|
+
}
|
|
2604
|
+
async buildParticles(
|
|
2605
|
+
/** Already stripped of directives — see compileEffect. */
|
|
2606
|
+
wgsl,
|
|
2607
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
2608
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
2609
|
+
d, anchors,
|
|
2569
2610
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
2570
2611
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
2571
2612
|
* describes the effect that is still on screen. */
|
|
2572
2613
|
alias) {
|
|
2573
2614
|
// No pragma means "some": an author who wrote the trio clearly wants
|
|
2574
2615
|
// particles, and failing over a missing comment would be pedantry.
|
|
2575
|
-
const count =
|
|
2576
|
-
const src = { wgsl, count, blend:
|
|
2616
|
+
const count = Math.min(d.particles || 1024, Engine.MAX_PARTICLES);
|
|
2617
|
+
const src = { wgsl, count, blend: d.particleBlend, bloom: d.bloom };
|
|
2577
2618
|
// Sparks want to spawn where a trail is, so the particle stages see the same
|
|
2578
2619
|
// cast buffer the trail draw reads.
|
|
2579
2620
|
const cast = {
|
|
@@ -2611,10 +2652,13 @@ export class Engine {
|
|
|
2611
2652
|
});
|
|
2612
2653
|
const uniform = this.device.createBuffer({
|
|
2613
2654
|
label: "particle uniforms",
|
|
2614
|
-
|
|
2655
|
+
// Two vec4-sized rows: (time, dt, count, frame) and (weight, _, _, _).
|
|
2656
|
+
// The first was exactly full, and weight has to live in the same buffer
|
|
2657
|
+
// as the clock or a frame could draw one without the other.
|
|
2658
|
+
size: 32,
|
|
2615
2659
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2616
2660
|
});
|
|
2617
|
-
const uniformBytes = new ArrayBuffer(
|
|
2661
|
+
const uniformBytes = new ArrayBuffer(32);
|
|
2618
2662
|
const uniformView = { floats: new Float32Array(uniformBytes), uints: new Uint32Array(uniformBytes) };
|
|
2619
2663
|
// Visibility is per LAYOUT, not shared: a read_write storage buffer may not be
|
|
2620
2664
|
// visible to the vertex stage at all (WebGPU forbids it — a vertex shader
|
|
@@ -2820,6 +2864,11 @@ export class Engine {
|
|
|
2820
2864
|
emitLights(encoder) {
|
|
2821
2865
|
for (const e of this.effects) {
|
|
2822
2866
|
const l = e.lights;
|
|
2867
|
+
// NOT skipped at weight 0, unlike every other mount. Each effect writes
|
|
2868
|
+
// its OWN slots in a shared buffer that is never cleared, so a skipped
|
|
2869
|
+
// dispatch leaves last frame's lights burning — the one place where not
|
|
2870
|
+
// running is the wrong answer. The shader zeroes them instead, and the
|
|
2871
|
+
// dispatch it costs is a single workgroup.
|
|
2823
2872
|
if (!l || l.data[2] === 0)
|
|
2824
2873
|
continue;
|
|
2825
2874
|
// The effect's OWN epoch — the same one its field, particle, ribbon and
|
|
@@ -2827,6 +2876,7 @@ export class Engine {
|
|
|
2827
2876
|
// clock that was shared from the first installed effect; that clock is
|
|
2828
2877
|
// per effect now, so every mount in one file agrees by construction.
|
|
2829
2878
|
l.data[0] = this.sceneClock - e.epochScene;
|
|
2879
|
+
l.data[3] = e.weight;
|
|
2830
2880
|
this.device.queue.writeBuffer(l.uniform, 0, l.data.buffer);
|
|
2831
2881
|
const cp = encoder.beginComputePass({ label: "light emit" });
|
|
2832
2882
|
cp.setPipeline(l.pipeline);
|
|
@@ -2841,6 +2891,11 @@ export class Engine {
|
|
|
2841
2891
|
if (!p)
|
|
2842
2892
|
continue;
|
|
2843
2893
|
p.data[0] = this.sceneClock - e.epochScene;
|
|
2894
|
+
// The SIMULATION runs at every weight, 0 included — only the draw stops.
|
|
2895
|
+
// A scheduled effect that froze while faded out would resume from the
|
|
2896
|
+
// state it left rather than the one it would have reached, so fading one
|
|
2897
|
+
// back in would rewind it.
|
|
2898
|
+
p.data[4] = e.weight;
|
|
2844
2899
|
// Clamped: a backgrounded tab returns with a delta of whole seconds, and an
|
|
2845
2900
|
// unclamped step flings every particle out of the scene in one frame.
|
|
2846
2901
|
p.data[1] = Math.min(0.1, Math.max(0, deltaTime));
|
|
@@ -2858,7 +2913,7 @@ export class Engine {
|
|
|
2858
2913
|
renderParticles(pass, view) {
|
|
2859
2914
|
for (const e of this.effects) {
|
|
2860
2915
|
const p = e.particles;
|
|
2861
|
-
if (!p)
|
|
2916
|
+
if (!p || e.weight === 0)
|
|
2862
2917
|
continue;
|
|
2863
2918
|
pass.setPipeline(p.render);
|
|
2864
2919
|
pass.setBindGroup(0, view === "mirror" ? p.mirrorRenderBind : p.renderBind);
|
|
@@ -2872,7 +2927,12 @@ export class Engine {
|
|
|
2872
2927
|
* and several declared bones is still one draw and nothing is computed per
|
|
2873
2928
|
* frame on the CPU.
|
|
2874
2929
|
*/
|
|
2875
|
-
async buildTrails(
|
|
2930
|
+
async buildTrails(
|
|
2931
|
+
/** Already stripped of directives — see compileEffect. */
|
|
2932
|
+
wgsl,
|
|
2933
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
2934
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
2935
|
+
d, anchors,
|
|
2876
2936
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
2877
2937
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
2878
2938
|
* describes the effect that is still on screen. */
|
|
@@ -2886,7 +2946,7 @@ export class Engine {
|
|
|
2886
2946
|
// nothing before: ribbon i was read as anchor slot i.
|
|
2887
2947
|
const ribbonSlots = anchors.map((a, i) => (a.trail ? i : -1)).filter((i) => i >= 0);
|
|
2888
2948
|
const slots = ribbonSlots.length;
|
|
2889
|
-
const src = { wgsl, slots, ribbonSlots, blend:
|
|
2949
|
+
const src = { wgsl, slots, ribbonSlots, blend: d.particleBlend, bloom: d.bloom };
|
|
2890
2950
|
const code = buildTrailShader(src, {
|
|
2891
2951
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
2892
2952
|
samples: TRAIL_SAMPLES,
|
|
@@ -3023,7 +3083,7 @@ export class Engine {
|
|
|
3023
3083
|
* Takes the pass rather than opening one: that IS the change.
|
|
3024
3084
|
*/
|
|
3025
3085
|
drawTrails(pass, view) {
|
|
3026
|
-
const drawn = this.effects.filter((e) => e.trails);
|
|
3086
|
+
const drawn = this.effects.filter((e) => e.trails && e.weight > 0);
|
|
3027
3087
|
if (drawn.length === 0)
|
|
3028
3088
|
return;
|
|
3029
3089
|
for (const e of drawn) {
|
|
@@ -3047,6 +3107,7 @@ export class Engine {
|
|
|
3047
3107
|
if (view === "camera") {
|
|
3048
3108
|
t.data[0] = this.sceneClock - e.epochScene;
|
|
3049
3109
|
t.data[1] = live;
|
|
3110
|
+
t.data[2] = e.weight;
|
|
3050
3111
|
this.device.queue.writeBuffer(t.uniform, 0, t.data.buffer);
|
|
3051
3112
|
}
|
|
3052
3113
|
pass.setPipeline(t.pipeline);
|
|
@@ -3058,9 +3119,25 @@ export class Engine {
|
|
|
3058
3119
|
* upsample. Runs the whole quad — uniform control flow, so effects may use
|
|
3059
3120
|
* derivatives freely, which the old inline path had to forbid. */
|
|
3060
3121
|
renderFieldPass(encoder) {
|
|
3061
|
-
|
|
3062
|
-
|
|
3122
|
+
// TWO PREDICATES, deliberately, and they are not interchangeable.
|
|
3123
|
+
//
|
|
3124
|
+
// MOUNTED decides whether the pass runs, and it must agree exactly with
|
|
3125
|
+
// fieldPairUsed — that is what the composite's bind group was built against,
|
|
3126
|
+
// at install, and it is not rebuilt per frame. A pass skipped under a
|
|
3127
|
+
// binding that still points at its target leaves the last frame it drew
|
|
3128
|
+
// sitting there, so an effect faded to nothing would freeze on screen
|
|
3129
|
+
// instead of disappearing.
|
|
3130
|
+
//
|
|
3131
|
+
// DRAWN decides what is drawn into it, and this is where weight is worth
|
|
3132
|
+
// something: a field mount is a full-screen quad however little of the frame
|
|
3133
|
+
// it ends up touching, so an effect that is scheduled off would otherwise
|
|
3134
|
+
// shade every pixel to multiply it out to nothing. The pass still clears —
|
|
3135
|
+
// which is what makes the layer transparent rather than stale — and shades
|
|
3136
|
+
// nothing.
|
|
3137
|
+
const mounted = this.effects.filter((e) => e.fieldPipeline && e.fieldBindGroups);
|
|
3138
|
+
if (mounted.length === 0)
|
|
3063
3139
|
return;
|
|
3140
|
+
const drawn = mounted.filter((e) => e.weight > 0);
|
|
3064
3141
|
// Each effect's own clock, before the pass that reads it. Seconds since
|
|
3065
3142
|
// THIS effect was installed — so an effect added to a running scene starts
|
|
3066
3143
|
// at zero and can seed, rather than joining whatever the first one is up to.
|
|
@@ -3068,6 +3145,7 @@ export class Engine {
|
|
|
3068
3145
|
if (!e.fieldClock)
|
|
3069
3146
|
continue;
|
|
3070
3147
|
this.fieldClockScratch[0] = this.sceneClock - e.epochScene;
|
|
3148
|
+
this.fieldClockScratch[1] = e.weight;
|
|
3071
3149
|
this.device.queue.writeBuffer(e.fieldClock, 0, this.fieldClockScratch.buffer);
|
|
3072
3150
|
}
|
|
3073
3151
|
// ONE PASS PER RESOLUTION, N draws each, in document order — a pair is
|
|
@@ -3082,7 +3160,7 @@ export class Engine {
|
|
|
3082
3160
|
// (fieldLayerView). Clearing and storing an empty full-res rgba16f pair is
|
|
3083
3161
|
// two 16MB writes a frame to produce the transparent black the fallback
|
|
3084
3162
|
// already is. Most scenes leave the full-res pair empty, since an effect only
|
|
3085
|
-
// lands there by declaring
|
|
3163
|
+
// lands there by declaring #fullres.
|
|
3086
3164
|
let stamped = false;
|
|
3087
3165
|
for (let i = 0; i < Engine.FIELD_SCALES.length; i++) {
|
|
3088
3166
|
const bg = this.fieldBgViews[i];
|
|
@@ -3096,7 +3174,7 @@ export class Engine {
|
|
|
3096
3174
|
{ view: fg, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: "clear", storeOp: "store" },
|
|
3097
3175
|
],
|
|
3098
3176
|
// One query pair is reserved for "field", and it goes to the first pair
|
|
3099
|
-
// that actually runs — full res when something declared
|
|
3177
|
+
// that actually runs — full res when something declared #fullres, half
|
|
3100
3178
|
// otherwise. Pinning it to i === 0 would have measured a pass that, now
|
|
3101
3179
|
// that empty pairs are skipped, usually does not happen.
|
|
3102
3180
|
timestampWrites: stamped ? undefined : this.stamps("field"),
|
|
@@ -3160,12 +3238,17 @@ export class Engine {
|
|
|
3160
3238
|
* against: rzGridFrame() is 0 on the first step and every value it reads is
|
|
3161
3239
|
* zero, so seeding is just "if frame is 0, return the initial state".
|
|
3162
3240
|
*/
|
|
3163
|
-
async buildSim(
|
|
3241
|
+
async buildSim(
|
|
3242
|
+
/** Already stripped of directives — see compileEffect. */
|
|
3243
|
+
wgsl,
|
|
3244
|
+
/** What the file declared. Read here rather than re-parsed: the source no
|
|
3245
|
+
* longer carries the lines, and two readers is how they drift. */
|
|
3246
|
+
d, anchors,
|
|
3164
3247
|
/** This effect's local→scene slot map. Passed rather than read off the
|
|
3165
3248
|
* engine: the builders run BEFORE the swap, so this.anchorTable still
|
|
3166
3249
|
* describes the effect that is still on screen. */
|
|
3167
3250
|
alias) {
|
|
3168
|
-
const size =
|
|
3251
|
+
const size = Math.min(d.grid || 256, GRID_MAX);
|
|
3169
3252
|
const cast = {
|
|
3170
3253
|
subjects: MAX_EFFECT_SUBJECTS,
|
|
3171
3254
|
samples: TRAIL_SAMPLES,
|
|
@@ -3300,10 +3383,19 @@ export class Engine {
|
|
|
3300
3383
|
getEffectMounts() {
|
|
3301
3384
|
return { background: this.effect?.hasBackground ?? false, foreground: this.effect?.hasForeground ?? false };
|
|
3302
3385
|
}
|
|
3303
|
-
/**
|
|
3304
|
-
*
|
|
3305
|
-
|
|
3306
|
-
|
|
3386
|
+
/**
|
|
3387
|
+
* Set one parameter on one INSTANCE.
|
|
3388
|
+
*
|
|
3389
|
+
* By index, because the scene holds a list and the same effect may appear in
|
|
3390
|
+
* it twice with different values — which is the whole point of an instance
|
|
3391
|
+
* and was impossible while this addressed `this.effect`, a singular left over
|
|
3392
|
+
* from when a scene could wear exactly one.
|
|
3393
|
+
*
|
|
3394
|
+
* A write, not a recompile: parameters live in their own uniform buffer, so
|
|
3395
|
+
* dragging a slider costs a 16-byte upload rather than a shader build.
|
|
3396
|
+
*/
|
|
3397
|
+
setEffectParam(index, name, value) {
|
|
3398
|
+
const fx = this.effects[index];
|
|
3307
3399
|
if (!fx || !fx.paramsBuffer)
|
|
3308
3400
|
return;
|
|
3309
3401
|
const slot = fx.paramLayout.get(name);
|
|
@@ -3318,6 +3410,114 @@ export class Engine {
|
|
|
3318
3410
|
}
|
|
3319
3411
|
this.device.queue.writeBuffer(fx.paramsBuffer, 0, fx.paramsData);
|
|
3320
3412
|
}
|
|
3413
|
+
/**
|
|
3414
|
+
* How much of one instance is showing, 0..1.
|
|
3415
|
+
*
|
|
3416
|
+
* The third of the three things an instance has — parameters, weight, time —
|
|
3417
|
+
* and the one a scheduler drives. Weight is not a parameter: a parameter is
|
|
3418
|
+
* whatever the author decided to expose and means only what their source
|
|
3419
|
+
* makes it mean, while weight means the same thing for every effect ever
|
|
3420
|
+
* written, including one whose author never heard of it. That is why it is
|
|
3421
|
+
* applied by engine-generated code at each mount's output rather than handed
|
|
3422
|
+
* to the source as a uniform to respect.
|
|
3423
|
+
*
|
|
3424
|
+
* At 0 nothing is drawn: no field quad, no particle draw, no ribbon, no light
|
|
3425
|
+
* dispatch. A scheduled effect outside its window costs its simulation and
|
|
3426
|
+
* nothing else — and a particle effect keeps simulating on purpose, so that
|
|
3427
|
+
* fading one back in continues rather than rewinds.
|
|
3428
|
+
*
|
|
3429
|
+
* Instant, and free: a float in a uniform every mount already uploads once a
|
|
3430
|
+
* frame. Nothing recompiles, so this is safe to drive per frame from a
|
|
3431
|
+
* timeline.
|
|
3432
|
+
*/
|
|
3433
|
+
setEffectInfluence(index, influence) {
|
|
3434
|
+
const fx = this.effects[index];
|
|
3435
|
+
if (!fx)
|
|
3436
|
+
return;
|
|
3437
|
+
// Clamped rather than trusted: above 1 the field's own clamp would swallow
|
|
3438
|
+
// it while an additive particle would happily keep getting brighter, so the
|
|
3439
|
+
// same number would mean two things.
|
|
3440
|
+
fx.influence = Math.min(1, Math.max(0, influence));
|
|
3441
|
+
}
|
|
3442
|
+
getEffectInfluence(index) {
|
|
3443
|
+
return this.effects[index]?.influence ?? 0;
|
|
3444
|
+
}
|
|
3445
|
+
/**
|
|
3446
|
+
* Schedule one instance: when it is alive, and how it enters and leaves.
|
|
3447
|
+
*
|
|
3448
|
+
* Null is the unscheduled case — on for the whole scene, on the scene's own
|
|
3449
|
+
* clock — and is what an effect starts as.
|
|
3450
|
+
*
|
|
3451
|
+
* The engine evaluates this every frame rather than taking a weight from a
|
|
3452
|
+
* caller, because every loop that renders would otherwise have to remember to
|
|
3453
|
+
* drive it. The offline export loop already carries a scar about exactly that
|
|
3454
|
+
* shape of bug. Evaluating where the scene clock advances means playback and
|
|
3455
|
+
* export cannot disagree, and neither can forget.
|
|
3456
|
+
*
|
|
3457
|
+
* A caller that wants to drive an effect from something OTHER than the scene
|
|
3458
|
+
* clock — an animation's progress, a skill firing — leaves this null and
|
|
3459
|
+
* writes setEffectInfluence and setEffectTime itself, per frame. Both paths
|
|
3460
|
+
* exist on purpose; this one is what a timeline wants.
|
|
3461
|
+
*/
|
|
3462
|
+
setEffectSchedule(index, windows) {
|
|
3463
|
+
const fx = this.effects[index];
|
|
3464
|
+
if (!fx)
|
|
3465
|
+
return;
|
|
3466
|
+
fx.window = windows && windows.length ? windows : null;
|
|
3467
|
+
}
|
|
3468
|
+
getEffectSchedule(index) {
|
|
3469
|
+
return this.effects[index]?.window ?? null;
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Every scheduled effect, at the current scene clock.
|
|
3473
|
+
*
|
|
3474
|
+
* Called once a frame, BEFORE anything reads a weight or a clock. An effect
|
|
3475
|
+
* with no window keeps whatever a caller last set, which is what makes the
|
|
3476
|
+
* manual path above work — evaluating it would fight the caller for the field
|
|
3477
|
+
* every frame.
|
|
3478
|
+
*/
|
|
3479
|
+
evaluateEffectSchedules() {
|
|
3480
|
+
// Read ONCE: it walks the cast, and every effect wants the same answer.
|
|
3481
|
+
const transport = this.transportTime();
|
|
3482
|
+
for (const fx of this.effects) {
|
|
3483
|
+
if (!fx.window || fx.window.length === 0) {
|
|
3484
|
+
fx.weight = fx.influence;
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
const at = effectState(fx.window, fx.influence, transport);
|
|
3488
|
+
fx.weight = at.weight;
|
|
3489
|
+
// Its own clock, expressed the way the mounts read it. Every mount
|
|
3490
|
+
// derives time from the epoch against sceneClock, so this one write moves
|
|
3491
|
+
// the field, the particles, the ribbons, lightEmit and the grid together
|
|
3492
|
+
// — and hands them the STRIP's local time while they keep running on the
|
|
3493
|
+
// smooth monotonic clock a particle integrator needs.
|
|
3494
|
+
fx.epochScene = this.sceneClock - at.time;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
/**
|
|
3498
|
+
* Move one instance's own clock to a given second.
|
|
3499
|
+
*
|
|
3500
|
+
* Everything an effect can animate is derived from its epoch — the field
|
|
3501
|
+
* clock, the particle and ribbon clocks, lightEmit's time argument, the grid's
|
|
3502
|
+
* frame counter — so moving the epoch moves all of them together and there is
|
|
3503
|
+
* no mount that can be left reading last frame's time.
|
|
3504
|
+
*
|
|
3505
|
+
* This is what lets an effect be SCHEDULED rather than merely switched on: an
|
|
3506
|
+
* instance that enters at bar 33 is handed a time that starts at zero there,
|
|
3507
|
+
* so it plays its own opening instead of joining whatever the scene clock had
|
|
3508
|
+
* reached. Feeding it the transport's time instead gives the other reading —
|
|
3509
|
+
* an effect that runs in lockstep with the music — and both are one call.
|
|
3510
|
+
*/
|
|
3511
|
+
setEffectTime(index, time) {
|
|
3512
|
+
const fx = this.effects[index];
|
|
3513
|
+
if (!fx)
|
|
3514
|
+
return;
|
|
3515
|
+
fx.epochScene = this.sceneClock - time;
|
|
3516
|
+
}
|
|
3517
|
+
getEffectTime(index) {
|
|
3518
|
+
const fx = this.effects[index];
|
|
3519
|
+
return fx ? this.sceneClock - fx.epochScene : 0;
|
|
3520
|
+
}
|
|
3321
3521
|
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
3322
3522
|
/** Camera depth of field (see DepthOfFieldOptions). Free while disabled —
|
|
3323
3523
|
* the scene pass only stores its depth buffer on frames the gather reads. */
|
|
@@ -3345,7 +3545,9 @@ export class Engine {
|
|
|
3345
3545
|
return null;
|
|
3346
3546
|
const view = this.camera.getViewMatrix().values;
|
|
3347
3547
|
for (const inst of this.modelInstances.values()) {
|
|
3348
|
-
|
|
3548
|
+
// Neither a stage nor a plane is a performer, so neither is a subject an
|
|
3549
|
+
// effect can follow.
|
|
3550
|
+
if (!inst.model.visible || inst.isStage || inst.isPlane)
|
|
3349
3551
|
continue;
|
|
3350
3552
|
const model = inst.model;
|
|
3351
3553
|
const matrices = model.getWorldMatrices();
|
|
@@ -4866,7 +5068,7 @@ export class Engine {
|
|
|
4866
5068
|
format: this.hdrFormat,
|
|
4867
5069
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
4868
5070
|
});
|
|
4869
|
-
// The field layer — half resolution by default, full for
|
|
5071
|
+
// The field layer — half resolution by default, full for #fullres effects.
|
|
4870
5072
|
this.fieldFullW = width;
|
|
4871
5073
|
this.fieldFullH = height;
|
|
4872
5074
|
this.createFieldTargets();
|
|
@@ -5701,10 +5903,24 @@ export class Engine {
|
|
|
5701
5903
|
this.cameraAnimation = null;
|
|
5702
5904
|
this.camera.setVmdDriven(false);
|
|
5703
5905
|
}
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5906
|
+
/**
|
|
5907
|
+
* THE TRANSPORT'S CLOCK — where the scene is in its own playback.
|
|
5908
|
+
*
|
|
5909
|
+
* The first model with an active clip (playing or scrubbed), so a static stage
|
|
5910
|
+
* never freezes it at frame 0. Falls back to the first model with a clip, then
|
|
5911
|
+
* to 0 for an empty scene.
|
|
5912
|
+
*
|
|
5913
|
+
* NOT `sceneClock`, and the difference is the whole reason this has a name.
|
|
5914
|
+
* `sceneClock` only ever accumulates delta — it is how long the engine has
|
|
5915
|
+
* been running, it does not move when you scrub, and it does not stop when you
|
|
5916
|
+
* pause. Anything that should line up with what the transport shows has to
|
|
5917
|
+
* read THIS. An effect scheduled to frame 100 against sceneClock fires once,
|
|
5918
|
+
* a hundred frames after the page loaded, and never again.
|
|
5919
|
+
*
|
|
5920
|
+
* Deterministic offline: the export loop advances model animation by an exact
|
|
5921
|
+
* per-frame delta, so this reproduces frame for frame.
|
|
5922
|
+
*/
|
|
5923
|
+
transportTime() {
|
|
5708
5924
|
let fallback = null;
|
|
5709
5925
|
for (const inst of this.modelInstances.values()) {
|
|
5710
5926
|
// Stages are skipped outright. Scenery carries no motion, and it is added
|
|
@@ -5712,7 +5928,7 @@ export class Engine {
|
|
|
5712
5928
|
// is first in insertion order and was seeding this clock with its own
|
|
5713
5929
|
// permanent zero. In a scene with a stage, a camera VMD therefore sampled
|
|
5714
5930
|
// frame 0 forever and the shot never moved.
|
|
5715
|
-
if (inst.isStage)
|
|
5931
|
+
if (inst.isStage || inst.isPlane)
|
|
5716
5932
|
continue;
|
|
5717
5933
|
const p = inst.model.getAnimationProgress();
|
|
5718
5934
|
if (p.playing || p.paused)
|
|
@@ -6054,7 +6270,7 @@ export class Engine {
|
|
|
6054
6270
|
const reader = assetReader ?? createFetchAssetReader();
|
|
6055
6271
|
const basePath = deriveBasePathFromPmxPath(pmxPath);
|
|
6056
6272
|
model.setAssetContext(reader, basePath);
|
|
6057
|
-
await this.setupModelInstance(key, model, basePath, reader, options?.stage ?? false);
|
|
6273
|
+
await this.setupModelInstance(key, model, basePath, reader, options?.stage ?? false, options?.plane ?? false, options?.dynamic ?? false);
|
|
6058
6274
|
return key;
|
|
6059
6275
|
}
|
|
6060
6276
|
/**
|
|
@@ -6082,6 +6298,163 @@ export class Engine {
|
|
|
6082
6298
|
this.setModelTransform(key, options.transform);
|
|
6083
6299
|
return key;
|
|
6084
6300
|
}
|
|
6301
|
+
/**
|
|
6302
|
+
* Put a picture in the scene as a flat card.
|
|
6303
|
+
*
|
|
6304
|
+
* The thing compositors arrange in a post tool's fake 3D space — Nuke calls
|
|
6305
|
+
* it a Card, After Effects a 3D layer, MMD 板ポリ — except the space here is
|
|
6306
|
+
* the real one. A card is occluded by anything in front of it, occludes what
|
|
6307
|
+
* is behind it, takes perspective when turned, and is caught by depth of
|
|
6308
|
+
* field like everything else, because it is ordinary geometry rather than a
|
|
6309
|
+
* layer composited afterwards.
|
|
6310
|
+
*
|
|
6311
|
+
* It is a MODEL, deliberately. Not a new kind of scene object with its own
|
|
6312
|
+
* list, its own persistence and its own selection: a card wants a position,
|
|
6313
|
+
* a rotation and a size, which is exactly what a model already has, and
|
|
6314
|
+
* everything built around models — the transform, the shadow settings, the
|
|
6315
|
+
* material editor, the asset bundle — works on it the day it exists. It is
|
|
6316
|
+
* not a STAGE, though: it skips the same machinery for the same reasons, and
|
|
6317
|
+
* leaves the floor alone. See ModelInstance.isPlane.
|
|
6318
|
+
*
|
|
6319
|
+
* @returns the model key, for setModelTransform and removeModel.
|
|
6320
|
+
*/
|
|
6321
|
+
async addPlane(options) {
|
|
6322
|
+
const { image, name, width, height } = options;
|
|
6323
|
+
const hw = Math.max(width, 1e-4) / 2;
|
|
6324
|
+
const hh = Math.max(height, 1e-4) / 2;
|
|
6325
|
+
// A quad on the XY plane, facing +Z, centred on its own origin — so a
|
|
6326
|
+
// rotation turns it about its middle and a position places its centre,
|
|
6327
|
+
// which is what a handle in the viewport implies.
|
|
6328
|
+
//
|
|
6329
|
+
// V IS FLIPPED, and this is the whole of it: a picture's rows run downward
|
|
6330
|
+
// from its top-left, a UV runs upward from the bottom-left, and a card that
|
|
6331
|
+
// renders its image upside down looks like a bug in everything else.
|
|
6332
|
+
// prettier-ignore
|
|
6333
|
+
const vertexData = new Float32Array([
|
|
6334
|
+
// x y z nx ny nz u v
|
|
6335
|
+
-hw, -hh, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0,
|
|
6336
|
+
hw, -hh, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
|
6337
|
+
hw, hh, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0,
|
|
6338
|
+
-hw, hh, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0,
|
|
6339
|
+
]);
|
|
6340
|
+
// Two triangles, counter-clockwise seen from +Z. A second pair wound the
|
|
6341
|
+
// other way is how "visible from behind" is done here, rather than a
|
|
6342
|
+
// per-material cull flag the rest of the engine has no concept of.
|
|
6343
|
+
const indices = options.doubleSided ? [0, 1, 2, 0, 2, 3, 0, 2, 1, 0, 3, 2] : [0, 1, 2, 0, 2, 3];
|
|
6344
|
+
const indexData = new Uint32Array(indices);
|
|
6345
|
+
// The texture table's one entry. The path is a key, not a location — the
|
|
6346
|
+
// reader below answers it from memory, so nothing is fetched and nothing is
|
|
6347
|
+
// written to disk.
|
|
6348
|
+
//
|
|
6349
|
+
// A PLAIN RELATIVE NAME under a plain directory, because the loader treats
|
|
6350
|
+
// this exactly as it treats a PMX's: it takes the model path's directory
|
|
6351
|
+
// and JOINS the texture entry onto it. A scheme-looking path went through
|
|
6352
|
+
// that as `plane://` + `plane://name` and matched nothing, so every card
|
|
6353
|
+
// came out with the untextured fallback. `plane/<name>` joins to
|
|
6354
|
+
// `plane/<name>` and stays unique per card, which the engine-wide texture
|
|
6355
|
+
// cache needs it to be.
|
|
6356
|
+
const texturePath = `plane/${name}`;
|
|
6357
|
+
const material = {
|
|
6358
|
+
name,
|
|
6359
|
+
diffuse: [1, 1, 1, 1],
|
|
6360
|
+
specular: [0, 0, 0],
|
|
6361
|
+
ambient: [0, 0, 0],
|
|
6362
|
+
shininess: 0,
|
|
6363
|
+
diffuseTextureIndex: 0,
|
|
6364
|
+
normalTextureIndex: -1,
|
|
6365
|
+
sphereTextureIndex: -1,
|
|
6366
|
+
sphereMode: 0,
|
|
6367
|
+
toonTextureIndex: -1,
|
|
6368
|
+
sharedToon: false,
|
|
6369
|
+
// 0 CARRIES TWO DECISIONS, both wanted, and both silent if changed.
|
|
6370
|
+
//
|
|
6371
|
+
// No inverted-hull outline (bit 0x10): a card is not a character, and a
|
|
6372
|
+
// black rim around a light leak is the opposite of what it is for.
|
|
6373
|
+
//
|
|
6374
|
+
// AND NO SHADOW (bit 0x04, which is what castsShadow reads). A card is
|
|
6375
|
+
// usually light or artwork rather than an object, and a rectangle of hard
|
|
6376
|
+
// shadow thrown across the stage by a gradient reads as the renderer
|
|
6377
|
+
// being broken. Set geometry that SHOULD cast one is the rarer case, and
|
|
6378
|
+
// it can say so.
|
|
6379
|
+
edgeFlag: 0,
|
|
6380
|
+
edgeColor: [0, 0, 0, 1],
|
|
6381
|
+
edgeSize: 0,
|
|
6382
|
+
vertexCount: indices.length,
|
|
6383
|
+
};
|
|
6384
|
+
// ONE BONE, because Model requires one — it throws on an empty skeleton,
|
|
6385
|
+
// every vertex has to be skinned to something, and a card has nothing to
|
|
6386
|
+
// articulate. It is never posed; the model transform is what moves a card.
|
|
6387
|
+
const skeleton = {
|
|
6388
|
+
bones: [{ name: "全ての親", parentIndex: -1, bindTranslation: [0, 0, 0], children: [] }],
|
|
6389
|
+
inverseBindMatrices: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
6390
|
+
};
|
|
6391
|
+
const vertexCount = 4;
|
|
6392
|
+
const joints = new Uint16Array(vertexCount * 4);
|
|
6393
|
+
const weights = new Uint8Array(vertexCount * 4);
|
|
6394
|
+
for (let i = 0; i < vertexCount; i++)
|
|
6395
|
+
weights[i * 4] = 255;
|
|
6396
|
+
const model = new Model(vertexData, indexData, [{ path: name, name }], [material], skeleton, { joints, weights }, { morphs: [] });
|
|
6397
|
+
// ITS OWN PATH, not addStage's. A plane and a stage skip the same machinery
|
|
6398
|
+
// and mean different things, and routing one through the other is how the
|
|
6399
|
+
// ground came to be suppressed by adding a picture.
|
|
6400
|
+
// The picture answers from memory, whatever it is asked for: a card has
|
|
6401
|
+
// exactly ONE texture, so there is nothing to disambiguate and no way for a
|
|
6402
|
+
// path to be wrong. Matching the string instead is what silently produced
|
|
6403
|
+
// untextured cards, because the loader composes that string itself.
|
|
6404
|
+
const reader = { readBinary: async () => image };
|
|
6405
|
+
const key = await this.addModel(model, texturePath, name, reader, { plane: true, dynamic: options.dynamic });
|
|
6406
|
+
// UNLIT, because a card is FOOTAGE and not a surface.
|
|
6407
|
+
//
|
|
6408
|
+
// Its pixels were finished somewhere else — a gradient painted in
|
|
6409
|
+
// Photoshop, a title, a rendered element — so its brightness is the artwork
|
|
6410
|
+
// rather than a response to anything. Shading it means the sun dimming one
|
|
6411
|
+
// side of a thing that has no sides, and the world colour tinting a picture
|
|
6412
|
+
// whose colour was the point. Left ungrouped it would take the neutral
|
|
6413
|
+
// Principled base, which is exactly that mistake.
|
|
6414
|
+
//
|
|
6415
|
+
// A group, not a hard-coded pipeline: a card used as SET geometry — a photo
|
|
6416
|
+
// of a wall, a poster standing in the room — genuinely does want the light,
|
|
6417
|
+
// and this is the same control every other material is changed through, so
|
|
6418
|
+
// that case is a graph swap rather than a feature request.
|
|
6419
|
+
await this.applyStyleGroups(key, [
|
|
6420
|
+
{ id: "plane", label: "Plane", materials: [name], graph: UNLIT_GRAPH, alphaMode: "hashed" },
|
|
6421
|
+
]);
|
|
6422
|
+
if (options.transform)
|
|
6423
|
+
this.setModelTransform(key, options.transform);
|
|
6424
|
+
// Kept so a moving card can push frames into it. The cache is keyed by the
|
|
6425
|
+
// texture's logical path, which is derived rather than stored anywhere the
|
|
6426
|
+
// caller can see — and deriving it twice is how the two would drift.
|
|
6427
|
+
const tex = this.textureCache.get(texturePath);
|
|
6428
|
+
if (tex)
|
|
6429
|
+
this.planeTextures.set(key, tex);
|
|
6430
|
+
return key;
|
|
6431
|
+
}
|
|
6432
|
+
/**
|
|
6433
|
+
* Replace what a card is showing, in place.
|
|
6434
|
+
*
|
|
6435
|
+
* For a moving card: a video element, a decoded frame, a canvas — anything
|
|
6436
|
+
* copyExternalImageToTexture accepts. Nothing is reallocated and no bind group
|
|
6437
|
+
* is rebuilt, so this is a per-frame call rather than a per-clip one; the
|
|
6438
|
+
* texture is written where it stands and the material keeps pointing at it.
|
|
6439
|
+
*
|
|
6440
|
+
* The frame must be the size the card was created at. A card is a fixed
|
|
6441
|
+
* rectangle of texels and resizing one mid-clip would mean rebuilding the
|
|
6442
|
+
* material behind it — so the caller allocates the card at its video's size
|
|
6443
|
+
* and this refuses anything else rather than stretching it silently.
|
|
6444
|
+
*/
|
|
6445
|
+
setPlaneFrame(id, source, width, height) {
|
|
6446
|
+
const tex = this.planeTextures.get(id);
|
|
6447
|
+
if (!tex || !this.device)
|
|
6448
|
+
return false;
|
|
6449
|
+
if (tex.width !== width || tex.height !== height)
|
|
6450
|
+
return false;
|
|
6451
|
+
this.device.queue.copyExternalImageToTexture({ source }, { texture: tex }, [width, height]);
|
|
6452
|
+
// A moving card is allocated with one level precisely so this is never
|
|
6453
|
+
// reached: rebuilding a mip pyramid per frame is a pass per level per card.
|
|
6454
|
+
if (tex.mipLevelCount > 1)
|
|
6455
|
+
this.generateMipmaps(tex, tex.mipLevelCount);
|
|
6456
|
+
return true;
|
|
6457
|
+
}
|
|
6085
6458
|
/** True while a stage is in the scene. Two things turn on it: the built-in
|
|
6086
6459
|
* ground plane must not draw, and the far shadow cascade has nothing to
|
|
6087
6460
|
* cover without one (see the cascade loop). */
|
|
@@ -6100,6 +6473,9 @@ export class Engine {
|
|
|
6100
6473
|
const inst = this.modelInstances.get(name);
|
|
6101
6474
|
if (!inst)
|
|
6102
6475
|
return;
|
|
6476
|
+
// Before the texture cache below frees it: a stale entry here would hand a
|
|
6477
|
+
// destroyed texture to the next setPlaneFrame.
|
|
6478
|
+
this.planeTextures.delete(name);
|
|
6103
6479
|
inst.model.stop();
|
|
6104
6480
|
for (const path of inst.textureCacheKeys) {
|
|
6105
6481
|
const tex = this.textureCache.get(path);
|
|
@@ -6352,10 +6728,10 @@ export class Engine {
|
|
|
6352
6728
|
// A stage never solves IK — nothing drives its chains — and skips the pose
|
|
6353
6729
|
// pass entirely while it is idle. Morph changes still come through, since
|
|
6354
6730
|
// that is the one thing a stage's controls do move.
|
|
6355
|
-
const stageIdle = inst.isStage && inst.model.isIdle();
|
|
6731
|
+
const stageIdle = (inst.isStage || inst.isPlane) && inst.model.isIdle();
|
|
6356
6732
|
let verticesChanged = false;
|
|
6357
6733
|
if (!stageIdle) {
|
|
6358
|
-
verticesChanged = inst.model.update(deltaTime, inst.isStage ? false : this.ikEnabled);
|
|
6734
|
+
verticesChanged = inst.model.update(deltaTime, inst.isStage || inst.isPlane ? false : this.ikEnabled);
|
|
6359
6735
|
inst.skinMatricesDirty = true;
|
|
6360
6736
|
}
|
|
6361
6737
|
animMs += performance.now() - tAnim;
|
|
@@ -7305,7 +7681,7 @@ export class Engine {
|
|
|
7305
7681
|
});
|
|
7306
7682
|
}
|
|
7307
7683
|
}
|
|
7308
|
-
async setupModelInstance(name, model, basePath, assetReader, isStage = false) {
|
|
7684
|
+
async setupModelInstance(name, model, basePath, assetReader, isStage = false, isPlane = false, dynamicTexture = false) {
|
|
7309
7685
|
const vertices = model.getVertices();
|
|
7310
7686
|
const skinning = model.getSkinning();
|
|
7311
7687
|
const skeleton = model.getSkeleton();
|
|
@@ -7348,7 +7724,7 @@ export class Engine {
|
|
|
7348
7724
|
// A stage never simulates, so its bodies are never built — constructing the
|
|
7349
7725
|
// solver for the heaviest mesh in the scene and dropping it afterwards was
|
|
7350
7726
|
// both wasted work and an invariant maintained in the wrong place.
|
|
7351
|
-
const physics = !isStage && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null;
|
|
7727
|
+
const physics = !isStage && !isPlane && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null;
|
|
7352
7728
|
// Which bones the simulation will overwrite, handed to the pose pipeline so
|
|
7353
7729
|
// the append (付与) pass can consume the simulated result instead of the
|
|
7354
7730
|
// animated one. Precomputed here, once, because the answer is topology —
|
|
@@ -7420,6 +7796,8 @@ export class Engine {
|
|
|
7420
7796
|
pickPerInstanceBindGroup,
|
|
7421
7797
|
pickDrawCalls: [],
|
|
7422
7798
|
isStage,
|
|
7799
|
+
isPlane,
|
|
7800
|
+
dynamicTexture,
|
|
7423
7801
|
// Seeded true: the bind pose has to reach the GPU once before any frame.
|
|
7424
7802
|
skinMatricesDirty: true,
|
|
7425
7803
|
hiddenMaterials: new Set(),
|
|
@@ -7841,7 +8219,21 @@ export class Engine {
|
|
|
7841
8219
|
bounds[3] += grow;
|
|
7842
8220
|
bounds[4] += grow;
|
|
7843
8221
|
bounds[5] += grow;
|
|
7844
|
-
|
|
8222
|
+
// A CARD IS ALWAYS OPAQUE-PHASE, whatever its alpha says.
|
|
8223
|
+
//
|
|
8224
|
+
// The scene pass runs opaque -> ground -> transparent, and the ground
|
|
8225
|
+
// writes depth at every opacity (effects locate the floor by it). Every
|
|
8226
|
+
// card qualifies as transparent — a cutout has translucent texels, and a
|
|
8227
|
+
// video card starts from a blank sheet that is nothing but — so cards
|
|
8228
|
+
// drew after the ground and an INVISIBLE floor rejected them. Turning the
|
|
8229
|
+
// ground down for the shadow catcher made pictures disappear into it.
|
|
8230
|
+
//
|
|
8231
|
+
// Not a workaround: alphaMode "hashed" is alpha-to-coverage, which is the
|
|
8232
|
+
// transparency technique built for this phase, and addPlane already sets
|
|
8233
|
+
// it. The cost is dithering on a large soft gradient, where MSAA has four
|
|
8234
|
+
// coverage levels to spend — a cutout edge, which is what a card usually
|
|
8235
|
+
// has, resolves exactly.
|
|
8236
|
+
const type = inst.isPlane ? "opaque" : isTransparent ? "transparent" : "opaque";
|
|
7845
8237
|
inst.drawCalls.push({
|
|
7846
8238
|
type,
|
|
7847
8239
|
count: indexCount,
|
|
@@ -8134,7 +8526,13 @@ export class Engine {
|
|
|
8134
8526
|
`so sheer fabric will not blend. The canvas 2D readback is what failed.`);
|
|
8135
8527
|
}
|
|
8136
8528
|
this.textureAlphaCache.set(cacheKey, alphaPlane);
|
|
8137
|
-
|
|
8529
|
+
// NO MIPS FOR A MOVING CARD. The chain would have to be rebuilt on every
|
|
8530
|
+
// frame written into it — a full pyramid of render passes per video plane
|
|
8531
|
+
// per frame, which is most of what a moving card was costing. Level 0 is
|
|
8532
|
+
// the only level a card in frame reads anyway; the price is aliasing on one
|
|
8533
|
+
// shrunk far into the distance, which is the case a video card is least
|
|
8534
|
+
// often in.
|
|
8535
|
+
const mipLevelCount = inst.dynamicTexture ? 1 : Math.floor(Math.log2(Math.max(width, height))) + 1;
|
|
8138
8536
|
const texture = this.device.createTexture({
|
|
8139
8537
|
label: `texture: ${cacheKey}`,
|
|
8140
8538
|
size: [width, height],
|
|
@@ -8623,7 +9021,7 @@ export class Engine {
|
|
|
8623
9021
|
}
|
|
8624
9022
|
// Drive the shot from the camera VMD (synced to the animated model's clock).
|
|
8625
9023
|
if (this.camera.vmdDriven && this.cameraAnimation) {
|
|
8626
|
-
const pose = this.cameraAnimation.sample(this.
|
|
9024
|
+
const pose = this.cameraAnimation.sample(this.transportTime());
|
|
8627
9025
|
if (pose)
|
|
8628
9026
|
this.camera.setVmdPose(pose);
|
|
8629
9027
|
}
|
|
@@ -8757,6 +9155,12 @@ export class Engine {
|
|
|
8757
9155
|
// uniforms this frame.
|
|
8758
9156
|
this.evaluateDissolveCycles();
|
|
8759
9157
|
this.evaluateParamTracks();
|
|
9158
|
+
// FIRST among the things that read an effect, because every one of them
|
|
9159
|
+
// reads what this writes: the sim's clock, the particle uniform's weight,
|
|
9160
|
+
// the light dispatch, the field draw. Evaluated here rather than by a
|
|
9161
|
+
// caller so that playback, the export loop and a warm-up pass cannot
|
|
9162
|
+
// disagree about when an effect is alive — none of them has to remember it.
|
|
9163
|
+
this.evaluateEffectSchedules();
|
|
8760
9164
|
this.stepSim(encoder, deltaTime);
|
|
8761
9165
|
this.stepParticles(encoder, deltaTime);
|
|
8762
9166
|
// Before the scene pass, which READS the slots this writes. Same buffer,
|
|
@@ -8819,7 +9223,7 @@ export class Engine {
|
|
|
8819
9223
|
this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView));
|
|
8820
9224
|
// Last in the pass: depth-tested against everything drawn above, so a
|
|
8821
9225
|
// particle behind the character is simply hidden, and still inside the HDR
|
|
8822
|
-
// target so an
|
|
9226
|
+
// target so an `#bloom` effect reaches the pyramid below.
|
|
8823
9227
|
this.renderParticles(pass, "camera");
|
|
8824
9228
|
// Ribbons, in the same pass and after the particles: both are additive
|
|
8825
9229
|
// light in HDR, and both reach the bloom pyramid because of it. This used
|
|
@@ -10261,14 +10665,14 @@ Engine.JIGGLE_DAMPING_SCALE = 0.5;
|
|
|
10261
10665
|
* cost a degenerate quad the rasteriser rejects, which is cheaper than the
|
|
10262
10666
|
* prefix sum and readback a compacted draw list would need every frame.
|
|
10263
10667
|
*/
|
|
10264
|
-
/** Ceiling for
|
|
10668
|
+
/** Ceiling for `#particles`. Past this an author is asking for a stall. */
|
|
10265
10669
|
Engine.MAX_PARTICLES = 65536;
|
|
10266
10670
|
/**
|
|
10267
10671
|
* The field layer: user background/foreground mounts, ONE TARGET PAIR PER
|
|
10268
10672
|
* RESOLUTION. Index 0 is full, index 1 is half — coarsest last, so the
|
|
10269
10673
|
* composite reads them full-over-half.
|
|
10270
10674
|
*
|
|
10271
|
-
*
|
|
10675
|
+
* `#fullres` used to be a property of the shared targets: one effect
|
|
10272
10676
|
* declaring it promoted the pass for every effect installed, so a starfield
|
|
10273
10677
|
* that upsamples perfectly paid four times the pixels because a keyboard
|
|
10274
10678
|
* beside it needed crisp edges. Measured, that was the largest avoidable cost
|
|
@@ -10292,7 +10696,7 @@ Engine.FIELD_SCALES = [1, 2];
|
|
|
10292
10696
|
*
|
|
10293
10697
|
* `field` earns its place now that a scene runs SEVERAL field effects at
|
|
10294
10698
|
* once: it is one pass with N draws, its resolution is a property of the
|
|
10295
|
-
* shared targets rather than of any one effect — so a single
|
|
10699
|
+
* shared targets rather than of any one effect — so a single `#fullres`
|
|
10296
10700
|
* effect quadruples the pixel count for all of them — and it is the pass the
|
|
10297
10701
|
* field restructure moves. Restructuring it while it was the only untimed
|
|
10298
10702
|
* pass in the frame would have meant reasoning about the cost instead of
|