reze-engine 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/effect-schedule.d.ts +67 -0
- package/dist/effect-schedule.d.ts.map +1 -0
- package/dist/effect-schedule.js +96 -0
- package/dist/engine.d.ts +152 -21
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +399 -204
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/shaders/anchor-table.d.ts +1 -1
- package/dist/shaders/anchor-table.js +3 -3
- package/dist/shaders/cast-api.d.ts +1 -1
- package/dist/shaders/cast-api.js +2 -2
- package/dist/shaders/directives.d.ts +73 -0
- package/dist/shaders/directives.d.ts.map +1 -0
- package/dist/shaders/directives.js +238 -0
- package/dist/shaders/lights.d.ts +0 -10
- package/dist/shaders/lights.d.ts.map +1 -1
- package/dist/shaders/lights.js +11 -19
- package/dist/shaders/passes/composite.d.ts +5 -5
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +12 -5
- package/dist/shaders/passes/grid.d.ts +0 -2
- package/dist/shaders/passes/grid.d.ts.map +1 -1
- package/dist/shaders/passes/grid.js +0 -9
- package/dist/shaders/passes/particles.d.ts +0 -6
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +15 -19
- package/dist/shaders/passes/scene-contract.d.ts +1 -1
- package/dist/shaders/passes/trails.d.ts.map +1 -1
- package/dist/shaders/passes/trails.js +9 -4
- package/package.json +2 -2
- package/src/effect-schedule.ts +120 -0
- package/src/engine.ts +398 -173
- package/src/index.ts +17 -1
- package/src/shaders/anchor-table.ts +3 -3
- package/src/shaders/cast-api.ts +2 -2
- package/src/shaders/directives.ts +289 -0
- package/src/shaders/lights.ts +11 -19
- package/src/shaders/passes/composite.ts +13 -6
- package/src/shaders/passes/grid.ts +0 -9
- package/src/shaders/passes/particles.ts +15 -21
- package/src/shaders/passes/scene-contract.ts +1 -1
- package/src/shaders/passes/trails.ts +9 -4
package/src/index.ts
CHANGED
|
@@ -26,7 +26,8 @@ export {
|
|
|
26
26
|
export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload"
|
|
27
27
|
export { parseMidi } from "./midi-loader"
|
|
28
28
|
// Radiance .hdr, for HDRI worlds — the host fetches the file and hands the
|
|
29
|
-
// parsed image to setBackdropEquirect
|
|
29
|
+
// parsed image to setWorldEquirect. Not setBackdropEquirect: that one is the
|
|
30
|
+
// 360 picture you SEE, and an HDRI is what LIGHTS the scene.
|
|
30
31
|
export { parseHDR, type HdrImage } from "./hdr"
|
|
31
32
|
// Lyrics timing (.lrc) — the host parses the file and hands the lines to setLyrics.
|
|
32
33
|
export { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, parseLRC, type LyricLine, type LyricRect } from "./shaders/lyrics-api"
|
|
@@ -34,6 +35,10 @@ export { LYRIC_ATLAS_MAX_H, LYRIC_ATLAS_MAX_W, parseLRC, type LyricLine, type Ly
|
|
|
34
35
|
// wants to draw a track, or scrub one, should read the same curve the engine
|
|
35
36
|
// plays rather than reimplementing it a second time.
|
|
36
37
|
export { sampleParamTrack, type ParamKey, type ParamValue } from "./param-track"
|
|
38
|
+
// The strip an effect is scheduled by, and the pure evaluator behind it —
|
|
39
|
+
// exported so a caller can draw a lane against the same numbers the engine
|
|
40
|
+
// renders from, rather than a second copy of the ramp maths.
|
|
41
|
+
export { effectState, type EffectWindow, type EffectState } from "./effect-schedule"
|
|
37
42
|
export {
|
|
38
43
|
compileGraph,
|
|
39
44
|
validateGraph,
|
|
@@ -70,6 +75,17 @@ export { STOCKINGS_GRAPH } from "./graph/presets/stockings"
|
|
|
70
75
|
export { EYE_GRAPH } from "./graph/presets/eye"
|
|
71
76
|
export { FACE_GRAPH } from "./graph/presets/face"
|
|
72
77
|
export { UNLIT_GRAPH } from "./graph/presets/unlit"
|
|
78
|
+
// What an effect declares, and the source with those lines blanked. Exported
|
|
79
|
+
// because a host builds parameter controls from the declarations and needs the
|
|
80
|
+
// same answer the engine got — two parsers is how they disagree.
|
|
81
|
+
export {
|
|
82
|
+
parseDirectives,
|
|
83
|
+
stripDirectives,
|
|
84
|
+
DIRECTIVE_LINE,
|
|
85
|
+
DIRECTIVE_NOTE,
|
|
86
|
+
type EffectDirectives,
|
|
87
|
+
type EffectParamDecl,
|
|
88
|
+
} from "./shaders/directives"
|
|
73
89
|
export {
|
|
74
90
|
Model,
|
|
75
91
|
MATERIAL_MORPH_MULTIPLY,
|
|
@@ -91,7 +91,7 @@ export function buildAnchorTable(requests: AnchorRequest[][], max: number): Anch
|
|
|
91
91
|
* TRAILED anchor, so its instance index counts 0,1,2… over trailed anchors only
|
|
92
92
|
* — while the cast buffer is addressed by DECLARATION slot. Those coincide
|
|
93
93
|
* exactly when every anchor is trailed, which is true of all 14 library effects
|
|
94
|
-
* and is why this stayed latent: declare
|
|
94
|
+
* and is why this stayed latent: declare `#anchor 頭` then `#anchor 左手首 trail`
|
|
95
95
|
* and ribbon 0 asked for the trail of 頭, which has none, so the ribbon silently
|
|
96
96
|
* did not draw.
|
|
97
97
|
*
|
|
@@ -127,7 +127,7 @@ ${cases}
|
|
|
127
127
|
* folds away.
|
|
128
128
|
*/
|
|
129
129
|
export function anchorAliasWgsl(alias: number[]): string {
|
|
130
|
-
// NO
|
|
130
|
+
// NO #anchor AT ALL. Every slot is unmapped, and rzAnchor() must report
|
|
131
131
|
// .valid false for all of them.
|
|
132
132
|
//
|
|
133
133
|
// This used to fall through to the identity branch below — `[].every()` is
|
|
@@ -139,7 +139,7 @@ export function anchorAliasWgsl(alias: number[]): string {
|
|
|
139
139
|
// reading.
|
|
140
140
|
if (alias.length === 0) {
|
|
141
141
|
return `
|
|
142
|
-
/** No
|
|
142
|
+
/** No #anchor in this effect: every slot is unmapped, so rzAnchor() is invalid
|
|
143
143
|
* rather than reading whichever bone another effect declared first. */
|
|
144
144
|
fn _rzSlot(local: i32) -> i32 { return -1; }
|
|
145
145
|
`
|
package/src/shaders/cast-api.ts
CHANGED
|
@@ -92,7 +92,7 @@ fn rzSubject(i: i32) -> RzSubject {
|
|
|
92
92
|
/**
|
|
93
93
|
* Where a named bone is, this frame.
|
|
94
94
|
*
|
|
95
|
-
* The slot is the author's own: the Nth
|
|
95
|
+
* The slot is the author's own: the Nth #anchor in their file, in the order
|
|
96
96
|
* they wrote them. _rzSlot turns that into the scene's address, which is what
|
|
97
97
|
* keeps two effects that both anchor to a wrist from reading each other's.
|
|
98
98
|
*/
|
|
@@ -115,7 +115,7 @@ fn rzAnchor(subject: i32, slot: i32) -> RzAnchor {
|
|
|
115
115
|
*
|
|
116
116
|
* Bounded by the anchor cap, NOT by how many anchors asked for a trail. Those
|
|
117
117
|
* are different index spaces: storage is addressed by anchor slot, so an
|
|
118
|
-
* untrailed
|
|
118
|
+
* untrailed #anchor followed by a trailed one put the trail at index 1 with a
|
|
119
119
|
* bound of 1 and rzTrail returned zero — a ribbon that silently did not draw.
|
|
120
120
|
*/
|
|
121
121
|
fn rzTrailCount(subject: i32, slot: i32) -> i32 {
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// What an effect DECLARES about itself — parsed once, in one place.
|
|
2
|
+
//
|
|
3
|
+
// An effect file is WGSL plus a handful of lines that configure how the engine
|
|
4
|
+
// mounts it: which resolution it draws at, how it blends, which bones it
|
|
5
|
+
// follows, what knobs it exposes. Those lines are not comments. They decide
|
|
6
|
+
// whether the file gets what it asks for, and the failure they used to have was
|
|
7
|
+
// the worst kind available.
|
|
8
|
+
//
|
|
9
|
+
// WHY `#` AND NOT `// @`. The old spelling put directives inside comments,
|
|
10
|
+
// which meant two things. Authors read them as comments and wrote notes on the
|
|
11
|
+
// same line — every parser was anchored to end-of-line, so the note silently
|
|
12
|
+
// unmade the directive, and three shipped effects ran at half the resolution
|
|
13
|
+
// their own first line said they needed while a fourth quietly stopped being
|
|
14
|
+
// additive. Nothing failed and nothing was reported, because nothing had been
|
|
15
|
+
// declared to fail. And it could not be fixed by being strict: a comment
|
|
16
|
+
// beginning with `@` is a legitimate thing to write, so an unrecognised one can
|
|
17
|
+
// only ever be a warning.
|
|
18
|
+
//
|
|
19
|
+
// WGSL has no `#` syntax of its own. A line starting with `#` is therefore
|
|
20
|
+
// unambiguously ours, an unknown one is an ERROR rather than a guess, and the
|
|
21
|
+
// sigil is the one every shader author already reads as "directive" from
|
|
22
|
+
// `#pragma`. The cost usually quoted for this — that the file stops being valid
|
|
23
|
+
// WGSL — is not a cost we pay: an effect calls rzSubject, rzTrail and reads
|
|
24
|
+
// `params`, none of which exist until the engine splices them in, so these
|
|
25
|
+
// files have never compiled anywhere else.
|
|
26
|
+
//
|
|
27
|
+
// STRIPPED BY BLANKING, not by deleting: the compiler sees an empty line where
|
|
28
|
+
// each directive was, so every diagnostic's line number still points at the
|
|
29
|
+
// line the author is looking at.
|
|
30
|
+
|
|
31
|
+
/** A knob an effect exposes, for a host to build a control from. */
|
|
32
|
+
export type EffectParamDecl = {
|
|
33
|
+
name: string
|
|
34
|
+
kind: "float" | "color" | "vec3"
|
|
35
|
+
/** Numbers for float/vec3; `#rrggbb` for a colour, which the host converts. */
|
|
36
|
+
value: number | [number, number, number] | string
|
|
37
|
+
/** float only, and only when the author gave a range. */
|
|
38
|
+
min?: number
|
|
39
|
+
max?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type EffectDirectives = {
|
|
43
|
+
/** Bones this effect follows, in declaration order — slot 0 is the first. */
|
|
44
|
+
anchors: { bone: string; trail: boolean }[]
|
|
45
|
+
params: EffectParamDecl[]
|
|
46
|
+
/** Field layer: 0 full, 1 half. Full unless `#halfres` says otherwise. */
|
|
47
|
+
fieldLayer: 0 | 1
|
|
48
|
+
/** The field layer composites additively rather than over. */
|
|
49
|
+
additiveLayer: boolean
|
|
50
|
+
/** Particle blend, which is a different axis from the field layer's. */
|
|
51
|
+
particleBlend: "alpha" | "additive"
|
|
52
|
+
particles: number
|
|
53
|
+
lights: number
|
|
54
|
+
grid: number
|
|
55
|
+
bloom: boolean
|
|
56
|
+
/** This effect takes the cast apart — the host reads the timing. */
|
|
57
|
+
dissolve: boolean
|
|
58
|
+
/**
|
|
59
|
+
* How long ONE firing of this effect lasts, in seconds. 0 = undeclared.
|
|
60
|
+
*
|
|
61
|
+
* An effect is one of two things, and only its author knows which. A HIT has
|
|
62
|
+
* an arc — a circle flares, peaks and is gone — and its length is a fact
|
|
63
|
+
* about it, the way a video clip's length is a fact about the file. An
|
|
64
|
+
* AMBIENT effect (stars, fog, rain) has no length at all; it is a condition
|
|
65
|
+
* the scene is in.
|
|
66
|
+
*
|
|
67
|
+
* Declaring it is what lets a host place the effect instead of making someone
|
|
68
|
+
* construct it: dropping a hit on the timeline gives a strip already the
|
|
69
|
+
* right size, and an effect that declares nothing spans the scene. Every
|
|
70
|
+
* timeline works this way — a clip arrives at its own duration.
|
|
71
|
+
*/
|
|
72
|
+
duration: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every directive, with how many words follow it. `rest` means free-form. */
|
|
76
|
+
const SPEC = {
|
|
77
|
+
anchor: "rest",
|
|
78
|
+
param: "rest",
|
|
79
|
+
halfres: 0,
|
|
80
|
+
layer: 1,
|
|
81
|
+
blend: 1,
|
|
82
|
+
particles: 1,
|
|
83
|
+
lights: 1,
|
|
84
|
+
grid: 1,
|
|
85
|
+
bloom: 0,
|
|
86
|
+
dissolve: 0,
|
|
87
|
+
duration: 1,
|
|
88
|
+
} as const
|
|
89
|
+
|
|
90
|
+
/** A line that declares something, and what it declares. Exported because an
|
|
91
|
+
* editor highlights by the same rule the parser reads by — a highlighter with
|
|
92
|
+
* its own idea of what counts is one that paints a line as configuration that
|
|
93
|
+
* the engine then ignores. */
|
|
94
|
+
export const DIRECTIVE_LINE = /^[ \t]*#([a-zA-Z]+)[ \t]*(.*)$/
|
|
95
|
+
const LINE = DIRECTIVE_LINE
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Split a directive's arguments from a trailing note.
|
|
99
|
+
*
|
|
100
|
+
* `#anchor 左手首 trail — her sword hand` is one line doing two jobs, and
|
|
101
|
+
* refusing it is what made the old spelling dangerous: the note is the natural
|
|
102
|
+
* thing to write, so it has to be the accepted thing to write.
|
|
103
|
+
*/
|
|
104
|
+
export const DIRECTIVE_NOTE = /(?:^|\s)(?:—|--|\/\/|#)\s/
|
|
105
|
+
|
|
106
|
+
function argsOf(rest: string): string[] {
|
|
107
|
+
// `^` as well as `\s`: the tag's own trailing space is eaten by LINE, so a
|
|
108
|
+
// note can begin at the very first character — which is what
|
|
109
|
+
// `#halfres — glyph edges` looks like by the time it reaches here.
|
|
110
|
+
const note = rest.search(DIRECTIVE_NOTE)
|
|
111
|
+
return (note >= 0 ? rest.slice(0, note) : rest).trim().split(/\s+/).filter(Boolean)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A number, or null. Empty is NULL, not zero: `Number("")` is 0, so a missing
|
|
115
|
+
* default silently became a real one — an author who wrote `#param float D`
|
|
116
|
+
* and meant to finish the line got a knob quietly pinned at zero. */
|
|
117
|
+
const num = (s: string | undefined): number | null => {
|
|
118
|
+
if (!s || !s.trim()) return null
|
|
119
|
+
const v = Number(s)
|
|
120
|
+
return Number.isFinite(v) ? v : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type DirectiveResult = { directives: EffectDirectives; errors: string[] }
|
|
124
|
+
|
|
125
|
+
/** Read every declaration in a source. Errors name the line, one-based. */
|
|
126
|
+
export function parseDirectives(wgsl: string): DirectiveResult {
|
|
127
|
+
const d: EffectDirectives = {
|
|
128
|
+
anchors: [],
|
|
129
|
+
params: [],
|
|
130
|
+
// FULL unless asked otherwise — the default is what an author gets for
|
|
131
|
+
// saying nothing, so it has to be the answer that cannot silently ruin an
|
|
132
|
+
// effect. `#halfres` is a claim about being cheap, which is a claim only
|
|
133
|
+
// the author can make.
|
|
134
|
+
fieldLayer: 0,
|
|
135
|
+
additiveLayer: false,
|
|
136
|
+
particleBlend: "alpha",
|
|
137
|
+
particles: 0,
|
|
138
|
+
lights: 0,
|
|
139
|
+
grid: 0,
|
|
140
|
+
bloom: false,
|
|
141
|
+
dissolve: false,
|
|
142
|
+
duration: 0,
|
|
143
|
+
}
|
|
144
|
+
const errors: string[] = []
|
|
145
|
+
const lines = wgsl.split("\n")
|
|
146
|
+
|
|
147
|
+
lines.forEach((line, i) => {
|
|
148
|
+
const m = LINE.exec(line)
|
|
149
|
+
if (!m) return
|
|
150
|
+
const at = `line ${i + 1}`
|
|
151
|
+
const tag = m[1].toLowerCase()
|
|
152
|
+
if (!(tag in SPEC)) {
|
|
153
|
+
errors.push(`${at}: #${m[1]} is not a directive. Known: ${Object.keys(SPEC).map((k) => `#${k}`).join(", ")}`)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const args = argsOf(m[2])
|
|
157
|
+
const want = SPEC[tag as keyof typeof SPEC]
|
|
158
|
+
if (want !== "rest" && args.length !== want) {
|
|
159
|
+
errors.push(`${at}: #${tag} takes ${want} argument${want === 1 ? "" : "s"}, got ${args.length}`)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
switch (tag) {
|
|
164
|
+
case "anchor": {
|
|
165
|
+
if (args.length < 1 || args.length > 2 || (args[1] && args[1] !== "trail")) {
|
|
166
|
+
errors.push(`${at}: #anchor takes a bone name and optionally the word "trail"`)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
d.anchors.push({ bone: args[0], trail: args[1] === "trail" })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
case "param": {
|
|
173
|
+
const [kind, name, ...rest] = args
|
|
174
|
+
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
175
|
+
errors.push(`${at}: #param needs a WGSL identifier for a name`)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (kind === "color") {
|
|
179
|
+
if (!/^#[0-9a-fA-F]{6}$/.test(rest[0] ?? "")) {
|
|
180
|
+
errors.push(`${at}: #param color ${name} needs a default like #3b82f6`)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
d.params.push({ name, kind: "color", value: rest[0] })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (kind === "vec3") {
|
|
187
|
+
const v = rest.slice(0, 3).map(num)
|
|
188
|
+
if (v.length !== 3 || v.some((x) => x === null)) {
|
|
189
|
+
errors.push(`${at}: #param vec3 ${name} needs three numbers`)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
d.params.push({ name, kind: "vec3", value: v as [number, number, number] })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (kind === "float") {
|
|
196
|
+
const v = num(rest[0])
|
|
197
|
+
if (v === null) {
|
|
198
|
+
errors.push(`${at}: #param float ${name} needs a default`)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
const lo = num(rest[1])
|
|
202
|
+
const hi = num(rest[2])
|
|
203
|
+
// A range is optional and all-or-nothing: half of one is a slider
|
|
204
|
+
// with an end nobody chose.
|
|
205
|
+
if ((rest[1] !== undefined) !== (rest[2] !== undefined) || (rest[1] !== undefined && (lo === null || hi === null))) {
|
|
206
|
+
errors.push(`${at}: #param float ${name} takes both a min and a max, or neither`)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
d.params.push({ name, kind: "float", value: v, ...(lo !== null && hi !== null ? { min: lo, max: hi } : {}) })
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
errors.push(`${at}: #param kind must be float, color or vec3`)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
case "halfres":
|
|
216
|
+
d.fieldLayer = 1
|
|
217
|
+
return
|
|
218
|
+
case "layer":
|
|
219
|
+
if (args[0] !== "additive") {
|
|
220
|
+
errors.push(`${at}: #layer takes "additive" — over is the default`)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
d.additiveLayer = true
|
|
224
|
+
return
|
|
225
|
+
case "blend":
|
|
226
|
+
if (args[0] !== "additive") {
|
|
227
|
+
errors.push(`${at}: #blend takes "additive" — alpha is the default`)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
d.particleBlend = "additive"
|
|
231
|
+
return
|
|
232
|
+
case "bloom":
|
|
233
|
+
d.bloom = true
|
|
234
|
+
return
|
|
235
|
+
case "dissolve":
|
|
236
|
+
d.dissolve = true
|
|
237
|
+
return
|
|
238
|
+
case "duration": {
|
|
239
|
+
// SECONDS, like every other time a directive states (see #dissolve).
|
|
240
|
+
// The document above works in frames and converts once; an author
|
|
241
|
+
// writing a shader is thinking about how long a flare takes, not about
|
|
242
|
+
// MMD's frame rate.
|
|
243
|
+
const n = num(args[0])
|
|
244
|
+
if (n === null || n <= 0) {
|
|
245
|
+
errors.push(`${at}: #duration takes a length in seconds`)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
d.duration = n
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
default: {
|
|
252
|
+
// The three that take a count.
|
|
253
|
+
const n = num(args[0])
|
|
254
|
+
if (n === null || n < 0) {
|
|
255
|
+
errors.push(`${at}: #${tag} takes a number`)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (tag === "particles") d.particles = n
|
|
259
|
+
else if (tag === "lights") d.lights = n
|
|
260
|
+
else if (tag === "grid") d.grid = n
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Duplicates are an author editing one line and forgetting another; last-wins
|
|
267
|
+
// is a coin toss they never see resolved.
|
|
268
|
+
const names = new Set<string>()
|
|
269
|
+
for (const p of d.params) {
|
|
270
|
+
if (names.has(p.name)) errors.push(`#param ${p.name} is declared twice`)
|
|
271
|
+
names.add(p.name)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { directives: d, errors }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The source as the compiler should see it: every directive line blanked.
|
|
279
|
+
*
|
|
280
|
+
* Blanked rather than removed so line numbers survive — a diagnostic points at
|
|
281
|
+
* the line the author is looking at, which is the whole reason the engine
|
|
282
|
+
* rebases them in the first place.
|
|
283
|
+
*/
|
|
284
|
+
export function stripDirectives(wgsl: string): string {
|
|
285
|
+
return wgsl
|
|
286
|
+
.split("\n")
|
|
287
|
+
.map((line) => (LINE.test(line) ? "" : line))
|
|
288
|
+
.join("\n")
|
|
289
|
+
}
|
package/src/shaders/lights.ts
CHANGED
|
@@ -45,21 +45,6 @@ export const MAX_LIGHTS = 16
|
|
|
45
45
|
/** Floats in the whole buffer. */
|
|
46
46
|
export const LIGHTS_FLOATS = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
* `// @lights 3` — how many lights this effect emits.
|
|
50
|
-
*
|
|
51
|
-
* Declared, like every other mount: what the file says is what gets allocated,
|
|
52
|
-
* so an effect that emits none costs no slots and nobody pays for a cap they
|
|
53
|
-
* did not ask for. Clamped rather than rejected, the same choice `@particles`
|
|
54
|
-
* makes — an author asking for a hundred gets the most the engine will give and
|
|
55
|
-
* a scene that still runs.
|
|
56
|
-
*/
|
|
57
|
-
export function parseLightCount(wgsl: string, max: number): number {
|
|
58
|
-
const m = /^\s*\/\/\s*@lights\s+(\d+)\s*$/m.exec(wgsl)
|
|
59
|
-
if (!m) return 0
|
|
60
|
-
return Math.max(1, Math.min(max, parseInt(m[1], 10)))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
48
|
/**
|
|
64
49
|
* The RzLight struct, declared in EVERY module a user's source is spliced into.
|
|
65
50
|
*
|
|
@@ -144,8 +129,8 @@ export function buildLightEmitShader(
|
|
|
144
129
|
// read_write HERE and read-only in the material shaders. Different passes, so
|
|
145
130
|
// the two never coexist: this compute runs before the scene pass that reads it.
|
|
146
131
|
@group(0) @binding(0) var<storage, read_write> _rzLightsOut: array<f32>;
|
|
147
|
-
// (time, base slot, count,
|
|
148
|
-
// here and not in the text.
|
|
132
|
+
// (time, base slot, count, weight) — see buildLightEmitShader on why the base
|
|
133
|
+
// is here and not in the text.
|
|
149
134
|
@group(0) @binding(1) var<uniform> _rzLightU: vec4f;
|
|
150
135
|
// The camera block and the cast — the two buffers the scene API reads. Same
|
|
151
136
|
// contents the field and grid modules bind, so an effect's lightEmit sees the
|
|
@@ -195,12 +180,19 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
|
|
|
195
180
|
let finite = l.pos.x == l.pos.x && l.pos.y == l.pos.y && l.pos.z == l.pos.z &&
|
|
196
181
|
l.radius == l.radius && l.intensity == l.intensity &&
|
|
197
182
|
l.color.x == l.color.x && l.color.y == l.color.y && l.color.z == l.color.z;
|
|
198
|
-
|
|
183
|
+
// Weight rides along with the sanitisation, which is already the one place
|
|
184
|
+
// this buffer is written: a lamp at half weight is half as bright, and one
|
|
185
|
+
// at zero never reaches here because the dispatch is skipped.
|
|
186
|
+
let c = select(vec3f(0.0), max(l.color * l.intensity, vec3f(0.0)), finite) * _rzLightU.w;
|
|
199
187
|
let b = ${LIGHT_HEADER}u + (u32(_rzLightU.y) + i) * ${LIGHT_STRIDE}u;
|
|
200
188
|
_rzLightsOut[b] = select(0.0, l.pos.x, finite);
|
|
201
189
|
_rzLightsOut[b + 1u] = select(0.0, l.pos.y, finite);
|
|
202
190
|
_rzLightsOut[b + 2u] = select(0.0, l.pos.z, finite);
|
|
203
|
-
|
|
191
|
+
// Radius goes to zero when the effect is OFF, and is left alone at every
|
|
192
|
+
// weight above it. Scaling it with the fade would shrink a dimming lamp's
|
|
193
|
+
// reach, which is a different thing than dimming it; zeroing it at nothing
|
|
194
|
+
// is what lets a material's distance cull drop the slot entirely.
|
|
195
|
+
_rzLightsOut[b + 3u] = select(0.0, max(l.radius, 0.0), finite && _rzLightU.w > 0.0);
|
|
204
196
|
// Colour carries intensity, exactly as the CPU writer stores it — one product,
|
|
205
197
|
// one place, so the two producers cannot disagree about what a slot means.
|
|
206
198
|
_rzLightsOut[b + 4u] = c.x;
|
|
@@ -58,13 +58,13 @@ import { gridReadApi } from "./grid"
|
|
|
58
58
|
/**
|
|
59
59
|
* The bones an effect asked for, in declaration order — the slots rzAnchor reads.
|
|
60
60
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
* #anchor 左手首 trail
|
|
62
|
+
* #anchor 頭
|
|
63
63
|
*
|
|
64
64
|
* A declaration in the source, like the mounts: what a file names is what gets
|
|
65
65
|
* resolved and uploaded, so naming none costs nothing and nobody pays for a
|
|
66
66
|
* rig's other five hundred bones. Anchored to the start of a line so that
|
|
67
|
-
* writing the word
|
|
67
|
+
* writing the word #anchor in ordinary prose does not silently add a slot —
|
|
68
68
|
* which would shift every slot after it.
|
|
69
69
|
*
|
|
70
70
|
* `trail` additionally keeps that bone's recent PATH, for rzTrail. Opt-in
|
|
@@ -75,7 +75,7 @@ import { gridReadApi } from "./grid"
|
|
|
75
75
|
* not have simply reports invalid.
|
|
76
76
|
*/
|
|
77
77
|
export function parseEffectAnchors(wgsl: string, max: number): { bone: string; trail: boolean }[] {
|
|
78
|
-
return [...wgsl.matchAll(/^[ \t]*\/\/[ \t]
|
|
78
|
+
return [...wgsl.matchAll(/^[ \t]*\/\/[ \t]*#anchor[ \t]+(\S+)([ \t]+trail)?[ \t]*$/gm)]
|
|
79
79
|
.map((m) => ({ bone: m[1], trail: m[2] !== undefined }))
|
|
80
80
|
.slice(0, max)
|
|
81
81
|
}
|
|
@@ -93,7 +93,7 @@ type CompositeEffectSource = {
|
|
|
93
93
|
hasBackground: boolean
|
|
94
94
|
/** Defines `fn foreground(...)` — mount over the finished frame. */
|
|
95
95
|
hasForeground: boolean
|
|
96
|
-
/** Grid resolution when the effect declared
|
|
96
|
+
/** Grid resolution when the effect declared `#grid`, else 0. */
|
|
97
97
|
gridSize: number
|
|
98
98
|
/** Whether the scene pass carries the id attachment, so the field module can
|
|
99
99
|
* bind it. False emits accessors that answer 0 rather than nothing at all. */
|
|
@@ -728,7 +728,7 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
|
|
|
728
728
|
* whose lightEmit read its own epoch disagreed with its own background()
|
|
729
729
|
* about what time it was. One buffer per effect, one answer.
|
|
730
730
|
*/
|
|
731
|
-
@group(0) @binding(22) var<uniform> _rzFieldClock: vec4f;
|
|
731
|
+
@group(0) @binding(22) var<uniform> _rzFieldClock: vec4f; // (time, weight, _, _)
|
|
732
732
|
|
|
733
733
|
@vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
734
734
|
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
@@ -752,6 +752,13 @@ struct FieldOut {
|
|
|
752
752
|
out.fg = vec4f(0.0);
|
|
753
753
|
${bgLine}
|
|
754
754
|
${fgLine}
|
|
755
|
+
// WEIGHT, applied where the author cannot decline it.
|
|
756
|
+
//
|
|
757
|
+
// Alpha only: both field blends multiply the fragment's colour by src-alpha,
|
|
758
|
+
// so this is the fade for the alpha-over layer and the additive one alike.
|
|
759
|
+
// Scaling colour as well would fade as the square.
|
|
760
|
+
out.bg.a *= _rzFieldClock.y;
|
|
761
|
+
out.fg.a *= _rzFieldClock.y;
|
|
755
762
|
return out;
|
|
756
763
|
}
|
|
757
764
|
`
|
|
@@ -40,15 +40,6 @@ export const GRID_MAX = 1024
|
|
|
40
40
|
/** rgba16float, ping-ponged. Two of these is the whole memory cost. */
|
|
41
41
|
export const SIM_FORMAT: GPUTextureFormat = "rgba16float"
|
|
42
42
|
|
|
43
|
-
/** `// @grid 256` — the grid's resolution, in texels per side. */
|
|
44
|
-
export function parseGridSize(wgsl: string, max: number): number {
|
|
45
|
-
const m = /^\s*\/\/\s*@grid\s+(\d+)\s*$/m.exec(wgsl)
|
|
46
|
-
if (!m) return 0
|
|
47
|
-
// Clamped rather than rejected, as the particle count is: an author asking for
|
|
48
|
-
// 4096 gets the most the engine will give and a scene that still runs.
|
|
49
|
-
return Math.max(8, Math.min(max, parseInt(m[1], 10)))
|
|
50
|
-
}
|
|
51
|
-
|
|
52
43
|
/** Whether this effect drives a grid at all. */
|
|
53
44
|
export function gridEntryPoint(wgsl: string): boolean {
|
|
54
45
|
return /\bfn\s+gridStep\s*\(/.test(wgsl)
|
|
@@ -102,6 +102,11 @@ struct ParticleU {
|
|
|
102
102
|
dt: f32,
|
|
103
103
|
count: u32,
|
|
104
104
|
frame: u32,
|
|
105
|
+
/** The effect's evaluated influence, applied at the one output site below. */
|
|
106
|
+
weight: f32,
|
|
107
|
+
_pad0: f32,
|
|
108
|
+
_pad1: f32,
|
|
109
|
+
_pad2: f32,
|
|
105
110
|
}
|
|
106
111
|
`
|
|
107
112
|
|
|
@@ -134,26 +139,6 @@ fn rzProject(p: vec3f) -> vec3f {
|
|
|
134
139
|
fn rzCamPos() -> vec3f { return cam.camPos; }
|
|
135
140
|
`
|
|
136
141
|
|
|
137
|
-
/** `// @particles 4096` — how many live at once. */
|
|
138
|
-
export function parseParticleCount(wgsl: string, max: number): number {
|
|
139
|
-
const m = /^\s*\/\/\s*@particles\s+(\d+)\s*$/m.exec(wgsl)
|
|
140
|
-
if (!m) return 0
|
|
141
|
-
// Clamped rather than rejected: an author asking for a million gets the most
|
|
142
|
-
// the engine will give and a scene that still runs, which is a better failure
|
|
143
|
-
// than a compile error naming a number they had no way to know.
|
|
144
|
-
return Math.max(1, Math.min(max, parseInt(m[1], 10)))
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
|
|
148
|
-
export function parseParticleBloom(wgsl: string): boolean {
|
|
149
|
-
return /^\s*\/\/\s*@bloom\s*$/m.test(wgsl)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** `// @blend additive` — default is straight alpha. */
|
|
153
|
-
export function parseParticleBlend(wgsl: string): ParticleBlend {
|
|
154
|
-
return /^\s*\/\/\s*@blend\s+additive\s*$/m.test(wgsl) ? "additive" : "alpha"
|
|
155
|
-
}
|
|
156
|
-
|
|
157
142
|
/** Does the source define the particle contract? All three are required together. */
|
|
158
143
|
export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean } {
|
|
159
144
|
return {
|
|
@@ -316,7 +301,16 @@ ${sceneIdFieldWgsl()}}
|
|
|
316
301
|
@fragment
|
|
317
302
|
fn fs(in: VSOut) -> FSOut {
|
|
318
303
|
let p = particles[in.id];
|
|
319
|
-
|
|
304
|
+
// WEIGHT IS APPLIED HERE, on the author's result, before anything reads it.
|
|
305
|
+
//
|
|
306
|
+
// Alpha alone, and that is not a shortcut: this fragment is premultiplied
|
|
307
|
+
// two lines down, so scaling alpha scales the colour with it — and where the
|
|
308
|
+
// target blends additively the premultiply is what carries the fade. One
|
|
309
|
+
// multiply is the correct fade for both particle targets.
|
|
310
|
+
var c = particleShade(p, in.uv);
|
|
311
|
+
c.a *= pu.weight;
|
|
312
|
+
// Weight 0 therefore discards every fragment, so an effect faded out costs
|
|
313
|
+
// nothing past the vertex stage even on the frame the draw is still issued.
|
|
320
314
|
if (c.a <= 0.0) { discard; }
|
|
321
315
|
var out: FSOut;
|
|
322
316
|
// PREMULTIPLIED: the scene's colour target blends with srcFactor \"one\", so a
|
|
@@ -86,7 +86,7 @@ type SceneRenderClass =
|
|
|
86
86
|
| "outline"
|
|
87
87
|
/** Particles and ribbons in their default, non-additive mode. */
|
|
88
88
|
| "particle"
|
|
89
|
-
/** Particles declaring
|
|
89
|
+
/** Particles declaring `#blend additive` — LIGHT rather than matter, so
|
|
90
90
|
* colour sums and alpha is left alone: a glow must not claim coverage it
|
|
91
91
|
* never occluded. The aux target sums with it, which is what lets an
|
|
92
92
|
* additive effect reach the bloom gate at all. */
|
|
@@ -132,7 +132,8 @@ struct TrailU {
|
|
|
132
132
|
// model is added or removed, and recompiling every trail shader for that
|
|
133
133
|
// would be absurd.
|
|
134
134
|
subjects: f32,
|
|
135
|
-
|
|
135
|
+
/** The effect's evaluated influence, applied at the one output site below. */
|
|
136
|
+
weight: f32,
|
|
136
137
|
_pad2: f32,
|
|
137
138
|
}
|
|
138
139
|
@group(0) @binding(0) var<storage, read> _rzCast: array<vec4f>;
|
|
@@ -402,15 +403,19 @@ fn fs(in: VSOut) -> TrailFSOut {
|
|
|
402
403
|
// hardware does it, correctly and for free, and the reversed-Z trap that
|
|
403
404
|
// needed its own regression test goes with it.
|
|
404
405
|
let c = trailShade(in.uv.x, in.uv.y, in.age, in.weight, i32(in.slot));
|
|
405
|
-
|
|
406
|
+
// THE EFFECT'S weight, not the ribbon's — in.weight above is the strand's
|
|
407
|
+
// own taper and belongs to the author. This one is the scheduler's, and it
|
|
408
|
+
// scales alpha because the target below multiplies colour by src-alpha.
|
|
409
|
+
let a = c.a * tu.weight;
|
|
410
|
+
if (a <= 0.0) { discard; }
|
|
406
411
|
var o: TrailFSOut;
|
|
407
412
|
// STRAIGHT colour into an ADDITIVE target, which reverses the old MAX rule
|
|
408
413
|
// deliberately: max existed so parallel strands could not double into bright
|
|
409
414
|
// dashes on a layer composited after tone mapping. In HDR before bloom,
|
|
410
415
|
// overlapping light SHOULD sum — that is what neon does — and the tone
|
|
411
416
|
// mapper is what keeps the sum from clipping.
|
|
412
|
-
o.color = vec4f(c.rgb,
|
|
413
|
-
o.aux = vec4f(${src.bloom ? "1.0" : "0.0"}, 1.0, 0.0,
|
|
417
|
+
o.color = vec4f(c.rgb, a);
|
|
418
|
+
o.aux = vec4f(${src.bloom ? "1.0" : "0.0"}, 1.0, 0.0, a);
|
|
414
419
|
${sceneIdPadWgsl("o")} return o;
|
|
415
420
|
}
|
|
416
421
|
`
|