reze-engine 0.41.4 → 0.42.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/README.md +26 -1
- package/dist/engine.d.ts +45 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +240 -4
- package/dist/model.d.ts +9 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +18 -0
- package/dist/shaders/passes/composite.d.ts +46 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +243 -13
- package/package.json +1 -1
- package/src/engine.ts +258 -4
- package/src/model.ts +17 -0
- package/src/shaders/passes/composite.ts +221 -14
package/src/engine.ts
CHANGED
|
@@ -34,7 +34,14 @@ import {
|
|
|
34
34
|
BLOOM_UPSAMPLE_SHADER_WGSL,
|
|
35
35
|
} from "./shaders/passes/bloom"
|
|
36
36
|
import { AGX_LUT_GZ, AGX_LUT_SIZE } from "./shaders/agx-lut"
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
buildCompositeShader,
|
|
39
|
+
parseEffectAnchors,
|
|
40
|
+
EFFECT_ANCHORS,
|
|
41
|
+
EFFECT_SUBJECTS,
|
|
42
|
+
EFFECT_TRAIL_BASE,
|
|
43
|
+
EFFECT_TRAIL_SAMPLES,
|
|
44
|
+
} from "./shaders/passes/composite"
|
|
38
45
|
import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
|
|
39
46
|
import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
|
|
40
47
|
import { compileGraph, type CompileOptions, type StyleSlot } from "./graph/compile"
|
|
@@ -234,14 +241,43 @@ export type WorldOptions = {
|
|
|
234
241
|
|
|
235
242
|
/** A model's scene placement — root offset baked into skinning + visibility. Serializable
|
|
236
243
|
* into a scene descriptor via getModelTransform. */
|
|
237
|
-
/** How many character positions an effect can read (viewU[11..14]).
|
|
238
|
-
|
|
244
|
+
/** How many character positions an effect can read (viewU[11..14]). Defined
|
|
245
|
+
* beside the shader that reads them — the layout arithmetic has to agree. */
|
|
246
|
+
const MAX_EFFECT_SUBJECTS = EFFECT_SUBJECTS
|
|
239
247
|
/** Where a character IS, for an effect that follows them. センター carries a
|
|
240
248
|
* motion's root movement — walking, jumping — where the model transform only
|
|
241
249
|
* carries where the model was placed; 全ての親 is the fallback for a model that
|
|
242
250
|
* animates the true root instead. */
|
|
243
251
|
const SUBJECT_BONES = ["センター", "全ての親"]
|
|
244
252
|
|
|
253
|
+
/** How many bones one effect may name. Eight is already a lot for one file, and
|
|
254
|
+
* this is a MINIMUM: raising it breaks nothing, because effects read through
|
|
255
|
+
* rzAnchor() rather than indexing the buffer. Lowering it would. */
|
|
256
|
+
const MAX_EFFECT_ANCHORS = EFFECT_ANCHORS
|
|
257
|
+
|
|
258
|
+
/** Only for the bounding sphere's height. */
|
|
259
|
+
const HEAD_BONE = "頭"
|
|
260
|
+
|
|
261
|
+
/** Path samples kept per trailed anchor. ~2.1s at the sampling rate below, which
|
|
262
|
+
* is a long ribbon — a dancer's arm draws most of a circle in that time.
|
|
263
|
+
*
|
|
264
|
+
* A MINIMUM, like every cap here, and raising it is why that matters: effects
|
|
265
|
+
* read through rzTrail and loop to rzTrailCount, so this went 64 → 128 without
|
|
266
|
+
* touching a single published effect. Lowering it is the direction that breaks. */
|
|
267
|
+
const TRAIL_SAMPLES = EFFECT_TRAIL_SAMPLES
|
|
268
|
+
/** Sampled on the SCENE clock at a fixed rate, so a path is identical in the
|
|
269
|
+
* editor, in an export and in a re-export, and its spacing does not change with
|
|
270
|
+
* the display's refresh. */
|
|
271
|
+
const TRAIL_HZ = 60
|
|
272
|
+
const TRAIL_DT = 1 / TRAIL_HZ
|
|
273
|
+
|
|
274
|
+
/** vec4 slots: four subjects × 3, then anchors × four subjects × 3, then the
|
|
275
|
+
* trails — slot-major, four subjects each, TRAIL_SAMPLES apiece. */
|
|
276
|
+
const CAST_SUBJECT_VEC4S = MAX_EFFECT_SUBJECTS * 3
|
|
277
|
+
const CAST_ANCHOR_VEC4S = MAX_EFFECT_ANCHORS * MAX_EFFECT_SUBJECTS * 3
|
|
278
|
+
const CAST_TRAIL_BASE = EFFECT_TRAIL_BASE
|
|
279
|
+
const CAST_VEC4S = CAST_TRAIL_BASE + MAX_EFFECT_ANCHORS * MAX_EFFECT_SUBJECTS * TRAIL_SAMPLES
|
|
280
|
+
|
|
245
281
|
export type ModelTransform = {
|
|
246
282
|
position: Vec3
|
|
247
283
|
rotation: Quat
|
|
@@ -840,7 +876,29 @@ export class Engine {
|
|
|
840
876
|
/** Mounted over the finished frame — and the reason the scene pass has to
|
|
841
877
|
* STORE its depth, which it otherwise discards into tile memory. */
|
|
842
878
|
hasForeground: boolean
|
|
879
|
+
/** Bones the source asked for, in declaration order — rzAnchor's slots. Only
|
|
880
|
+
* these are resolved and uploaded, so a file that names none costs nothing. */
|
|
881
|
+
anchors: { bone: string; trail: boolean }[]
|
|
843
882
|
} | null = null
|
|
883
|
+
/** The cast, as the effect API sees it. Written per frame while an effect is
|
|
884
|
+
* installed, and only up to what that effect actually declared. */
|
|
885
|
+
private castBuffer!: GPUBuffer
|
|
886
|
+
private castData!: Float32Array<ArrayBuffer>
|
|
887
|
+
/** Last frame's anchor world positions, for velocity. Keyed model id → slot. */
|
|
888
|
+
private anchorPrev = new Map<string, Float32Array>()
|
|
889
|
+
private castLastMs = 0
|
|
890
|
+
/** Recent path per trailed anchor, keyed "model\0slot". Newest first, so the
|
|
891
|
+
* shader's index 0 is now — written by unshifting rather than by tracking a
|
|
892
|
+
* head, because 64 is short and the alternative is an index the GPU side
|
|
893
|
+
* would also have to know about. */
|
|
894
|
+
private anchorTrail = new Map<string, { pos: number[]; t: number[] }>()
|
|
895
|
+
/** Scene seconds, advanced by the frame delta — NOT wall time, so an offline
|
|
896
|
+
* export samples the same path the editor showed. */
|
|
897
|
+
private sceneClock = 0
|
|
898
|
+
private trailAccum = 0
|
|
899
|
+
/** Trail samples owed this frame, computed once so every trail on every
|
|
900
|
+
* character samples in lockstep and their paths stay comparable. */
|
|
901
|
+
private trailDue = 0
|
|
844
902
|
private agxLutTexture: GPUTexture | null = null
|
|
845
903
|
private agxFallbackTexture!: GPUTexture
|
|
846
904
|
/** Bound at composite binding 7 when no effect (or a param-less one) is set. */
|
|
@@ -1191,6 +1249,7 @@ export class Engine {
|
|
|
1191
1249
|
|
|
1192
1250
|
private rebuildCompositeBindGroup(): void {
|
|
1193
1251
|
if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
|
|
1252
|
+
if (!this.castBuffer) return
|
|
1194
1253
|
this.compositeBindGroup = this.device.createBindGroup({
|
|
1195
1254
|
label: "composite bind group",
|
|
1196
1255
|
layout: this.compositeBindGroupLayout,
|
|
@@ -1206,6 +1265,7 @@ export class Engine {
|
|
|
1206
1265
|
{ binding: 8, resource: this.depthReadView },
|
|
1207
1266
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
1208
1267
|
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
1268
|
+
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
1209
1269
|
],
|
|
1210
1270
|
})
|
|
1211
1271
|
}
|
|
@@ -1337,6 +1397,14 @@ export class Engine {
|
|
|
1337
1397
|
}
|
|
1338
1398
|
const mounts = { background: hasBackground, foreground: hasForeground }
|
|
1339
1399
|
|
|
1400
|
+
// ── Which bones did the author ask for? Same idea as the mounts above: a
|
|
1401
|
+
// declaration in the source, not a setting somewhere else. Only what is
|
|
1402
|
+
// named here gets resolved and uploaded, so naming none costs nothing and
|
|
1403
|
+
// naming eight costs eight — rather than every rig's 500 bones costing
|
|
1404
|
+
// everybody. Past the cap the extras are dropped rather than silently
|
|
1405
|
+
// shifting every slot after them.
|
|
1406
|
+
const anchors = parseEffectAnchors(wgsl, MAX_EFFECT_ANCHORS)
|
|
1407
|
+
|
|
1340
1408
|
// ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
|
|
1341
1409
|
// Fields are emitted in declaration order; offsets follow WGSL's natural
|
|
1342
1410
|
// uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
|
|
@@ -1418,7 +1486,13 @@ export class Engine {
|
|
|
1418
1486
|
})
|
|
1419
1487
|
this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
|
|
1420
1488
|
}
|
|
1421
|
-
this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground }
|
|
1489
|
+
this.effect = { wgsl, paramLayout: layout, paramsBuffer, paramsData, hasBackground, hasForeground, anchors }
|
|
1490
|
+
// Velocities and paths restart from rest rather than continuing from
|
|
1491
|
+
// whatever the last effect's slot 0 happened to be — the slots mean
|
|
1492
|
+
// something different now, and a trail would otherwise draw a line from the
|
|
1493
|
+
// old bone to the new one across the whole scene.
|
|
1494
|
+
this.anchorPrev.clear()
|
|
1495
|
+
this.anchorTrail.clear()
|
|
1422
1496
|
this.compositePipelineIdentity = identity
|
|
1423
1497
|
this.compositePipelineGamma = gamma
|
|
1424
1498
|
this.effectEpochMs = performance.now()
|
|
@@ -2457,6 +2531,15 @@ export class Engine {
|
|
|
2457
2531
|
size: 16,
|
|
2458
2532
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2459
2533
|
})
|
|
2534
|
+
// Allocated at full size once rather than grown: it is ~7KB, the bind group
|
|
2535
|
+
// would otherwise be rebuilt whenever an effect declared a different number
|
|
2536
|
+
// of bones, and only the declared prefix is ever written.
|
|
2537
|
+
this.castData = new Float32Array(CAST_VEC4S * 4)
|
|
2538
|
+
this.castBuffer = this.device.createBuffer({
|
|
2539
|
+
label: "effect cast data",
|
|
2540
|
+
size: this.castData.byteLength,
|
|
2541
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
2542
|
+
})
|
|
2460
2543
|
this.compositeBindGroupLayout = this.device.createBindGroupLayout({
|
|
2461
2544
|
label: "composite bind group layout",
|
|
2462
2545
|
entries: [
|
|
@@ -2486,6 +2569,9 @@ export class Engine {
|
|
|
2486
2569
|
// AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
|
|
2487
2570
|
// 1×1×1 stand-in keeps the bind group valid until it arrives.
|
|
2488
2571
|
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: "3d" } },
|
|
2572
|
+
// The cast, for rzSubject/rzAnchor. Always bound so the base shader's
|
|
2573
|
+
// layout matches; the base shader simply never reads it.
|
|
2574
|
+
{ binding: 11, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
|
|
2489
2575
|
],
|
|
2490
2576
|
})
|
|
2491
2577
|
this.fallbackEquirectTexture = this.device.createTexture({
|
|
@@ -5393,6 +5479,13 @@ export class Engine {
|
|
|
5393
5479
|
}
|
|
5394
5480
|
|
|
5395
5481
|
private renderWithDelta(deltaTime: number) {
|
|
5482
|
+
// The scene clock, and the only clock a trail may sample on: renderFrame()
|
|
5483
|
+
// drives offline export with an exact per-frame delta, so a path recorded
|
|
5484
|
+
// against this is reproducible where one recorded against wall time is not.
|
|
5485
|
+
this.sceneClock += deltaTime
|
|
5486
|
+
this.trailAccum += deltaTime
|
|
5487
|
+
this.trailDue = Math.floor(this.trailAccum / TRAIL_DT)
|
|
5488
|
+
this.trailAccum -= this.trailDue * TRAIL_DT
|
|
5396
5489
|
const tFrame = performance.now()
|
|
5397
5490
|
this.frameAnimMsRaw = 0
|
|
5398
5491
|
this.framePhysicsMsRaw = 0
|
|
@@ -6218,11 +6311,172 @@ export class Engine {
|
|
|
6218
6311
|
u[44 + n * 4] = px
|
|
6219
6312
|
u[45 + n * 4] = py
|
|
6220
6313
|
u[46 + n * 4] = pz
|
|
6314
|
+
this.writeCastEntry(inst, n, px, py, pz)
|
|
6221
6315
|
n++
|
|
6222
6316
|
})
|
|
6223
6317
|
u[43] = n
|
|
6224
6318
|
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u)
|
|
6319
|
+
// Only what an effect declared, and only while one is installed. A scene
|
|
6320
|
+
// with no effect writes nothing here at all.
|
|
6321
|
+
if (this.effect) {
|
|
6322
|
+
// Up to the last trailed slot, not the whole buffer: an effect with no
|
|
6323
|
+
// trails never uploads the 32KB it would otherwise pay for every frame.
|
|
6324
|
+
let lastTrail = -1
|
|
6325
|
+
for (let i = 0; i < this.effect.anchors.length; i++) if (this.effect.anchors[i].trail) lastTrail = i
|
|
6326
|
+
const used =
|
|
6327
|
+
lastTrail >= 0
|
|
6328
|
+
? CAST_TRAIL_BASE + (lastTrail * MAX_EFFECT_SUBJECTS + MAX_EFFECT_SUBJECTS) * TRAIL_SAMPLES
|
|
6329
|
+
: CAST_SUBJECT_VEC4S + this.effect.anchors.length * MAX_EFFECT_SUBJECTS * 3
|
|
6330
|
+
this.device.queue.writeBuffer(this.castBuffer, 0, this.castData, 0, used * 4)
|
|
6331
|
+
this.castLastMs = performance.now()
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
|
|
6336
|
+
/**
|
|
6337
|
+
* One character's slice of the effect API's view of the cast.
|
|
6338
|
+
*
|
|
6339
|
+
* `px/py/pz` is the hip point the caller just composed — passed in rather than
|
|
6340
|
+
* recomputed, since it is the same two bone lookups.
|
|
6341
|
+
*
|
|
6342
|
+
* Bone positions are model-space, so each is scaled, rotated and translated by
|
|
6343
|
+
* the model transform exactly as the hip point above was. Getting that wrong
|
|
6344
|
+
* does not look wrong on a model standing at the origin, which is precisely
|
|
6345
|
+
* how it would ship.
|
|
6346
|
+
*/
|
|
6347
|
+
private writeCastEntry(inst: ModelInstance, n: number, px: number, py: number, pz: number): void {
|
|
6348
|
+
const effect = this.effect
|
|
6349
|
+
if (!effect) return
|
|
6350
|
+
const m = inst.model
|
|
6351
|
+
const cd = this.castData
|
|
6352
|
+
const toWorld = (v: Vec3): Vec3 => {
|
|
6353
|
+
v.setXYZ(v.x * m.scale, v.y * m.scale, v.z * m.scale)
|
|
6354
|
+
Quat.rotateVecInto(m.rotation, v, v)
|
|
6355
|
+
v.setXYZ(v.x + m.position.x, v.y + m.position.y, v.z + m.position.z)
|
|
6356
|
+
return v
|
|
6357
|
+
}
|
|
6358
|
+
|
|
6359
|
+
// The floor under this character: where the model was PLACED.
|
|
6360
|
+
//
|
|
6361
|
+
// A foot bone was the obvious answer and the wrong one. 足IK sits at the
|
|
6362
|
+
// ANKLE, not on the sole — an ankle above the ground even standing still,
|
|
6363
|
+
// and further still in heels — so a floor derived from it lands a hand's
|
|
6364
|
+
// width up the leg, which is exactly where the first version of Footfalls
|
|
6365
|
+
// drew its marks. A PMX's origin is between the feet on the floor by
|
|
6366
|
+
// convention, and placing a character on a stage moves that origin with
|
|
6367
|
+
// them, so the placement already answers "what is the ground here".
|
|
6368
|
+
//
|
|
6369
|
+
// Deliberately NOT the animated height: a jump lifts the character, not the
|
|
6370
|
+
// floor, and a floor that follows a jump is not a floor.
|
|
6371
|
+
const floorY = m.position.y
|
|
6372
|
+
// Generous on purpose: this is for culling, and a sphere that is too small
|
|
6373
|
+
// clips the effect it was meant to bound. Height is hip-to-head doubled;
|
|
6374
|
+
// arm span is about height on a human, so half of it is the radius, and the
|
|
6375
|
+
// rest is margin for a motion that reaches.
|
|
6376
|
+
const head = m.getBoneWorldPosition(HEAD_BONE)
|
|
6377
|
+
const height = head ? Math.max(0.01, toWorld(head).y - floorY) : Math.max(0.01, (py - floorY) * 2)
|
|
6378
|
+
const b = n * 12
|
|
6379
|
+
cd[b] = px
|
|
6380
|
+
cd[b + 1] = floorY
|
|
6381
|
+
cd[b + 2] = pz
|
|
6382
|
+
cd[b + 3] = 1
|
|
6383
|
+
cd[b + 4] = px
|
|
6384
|
+
cd[b + 5] = py
|
|
6385
|
+
cd[b + 6] = pz
|
|
6386
|
+
cd[b + 8] = px
|
|
6387
|
+
cd[b + 9] = floorY + height * 0.5
|
|
6388
|
+
cd[b + 10] = pz
|
|
6389
|
+
cd[b + 11] = height * 0.75
|
|
6390
|
+
|
|
6391
|
+
// Declared bones. Velocity is per model AND per slot, so two characters
|
|
6392
|
+
// wearing the same effect never inherit each other's motion.
|
|
6393
|
+
const anchors = effect.anchors
|
|
6394
|
+
if (anchors.length === 0) return
|
|
6395
|
+
let prev = this.anchorPrev.get(inst.name)
|
|
6396
|
+
const dtMs = Math.max(1, performance.now() - this.castLastMs)
|
|
6397
|
+
const invDt = 1000 / dtMs
|
|
6398
|
+
if (!prev || prev.length !== anchors.length * 3) {
|
|
6399
|
+
prev = new Float32Array(anchors.length * 3).fill(NaN)
|
|
6400
|
+
this.anchorPrev.set(inst.name, prev)
|
|
6401
|
+
}
|
|
6402
|
+
for (let s = 0; s < anchors.length; s++) {
|
|
6403
|
+
const a = CAST_SUBJECT_VEC4S * 4 + (s * MAX_EFFECT_SUBJECTS + n) * 12
|
|
6404
|
+
const pos = m.getBoneWorldPosition(anchors[s].bone)
|
|
6405
|
+
if (!pos) {
|
|
6406
|
+
cd[a + 3] = 0
|
|
6407
|
+
continue
|
|
6408
|
+
}
|
|
6409
|
+
toWorld(pos)
|
|
6410
|
+
const p = s * 3
|
|
6411
|
+
// NaN on the first frame a slot exists — a velocity out of nothing would
|
|
6412
|
+
// be a spike, and a trail or a spark reading it would fire on load.
|
|
6413
|
+
const vx = Number.isNaN(prev[p]) ? 0 : (pos.x - prev[p]) * invDt
|
|
6414
|
+
const vy = Number.isNaN(prev[p]) ? 0 : (pos.y - prev[p + 1]) * invDt
|
|
6415
|
+
const vz = Number.isNaN(prev[p]) ? 0 : (pos.z - prev[p + 2]) * invDt
|
|
6416
|
+
prev[p] = pos.x
|
|
6417
|
+
prev[p + 1] = pos.y
|
|
6418
|
+
prev[p + 2] = pos.z
|
|
6419
|
+
cd[a] = pos.x
|
|
6420
|
+
cd[a + 1] = pos.y
|
|
6421
|
+
cd[a + 2] = pos.z
|
|
6422
|
+
cd[a + 3] = 1
|
|
6423
|
+
cd[a + 4] = vx
|
|
6424
|
+
cd[a + 5] = vy
|
|
6425
|
+
cd[a + 6] = vz
|
|
6426
|
+
if (anchors[s].trail) this.writeTrail(inst.name, s, n, pos, cd, a)
|
|
6427
|
+
const fwd = m.getBoneWorldForward(anchors[s].bone)
|
|
6428
|
+
if (fwd) {
|
|
6429
|
+
Quat.rotateVecInto(m.rotation, fwd, fwd)
|
|
6430
|
+
cd[a + 8] = fwd.x
|
|
6431
|
+
cd[a + 9] = fwd.y
|
|
6432
|
+
cd[a + 10] = fwd.z
|
|
6433
|
+
}
|
|
6434
|
+
}
|
|
6435
|
+
}
|
|
6436
|
+
|
|
6437
|
+
/**
|
|
6438
|
+
* One trailed anchor's recent path, sampled on the scene clock and written
|
|
6439
|
+
* newest-first.
|
|
6440
|
+
*
|
|
6441
|
+
* Newest-first is what lets a ribbon be drawn by walking the index upward and
|
|
6442
|
+
* fading on age, and it means the shader never needs to know where the ring's
|
|
6443
|
+
* head is. Sixty-four entries is short enough that unshifting beats the
|
|
6444
|
+
* bookkeeping an actual ring buffer would push onto the GPU side too.
|
|
6445
|
+
*
|
|
6446
|
+
* Sampling is gated on TRAIL_DT of SCENE time, so a 120Hz display and a 30fps
|
|
6447
|
+
* export record the same path at the same spacing. A frame that covers several
|
|
6448
|
+
* intervals emits several samples rather than one, or a fast hand would tear.
|
|
6449
|
+
*/
|
|
6450
|
+
private writeTrail(model: string, slot: number, n: number, pos: Vec3, cd: Float32Array, anchorBase: number): void {
|
|
6451
|
+
const key = `${model}\u0000${slot}`
|
|
6452
|
+
let ring = this.anchorTrail.get(key)
|
|
6453
|
+
if (!ring) {
|
|
6454
|
+
ring = { pos: [], t: [] }
|
|
6455
|
+
this.anchorTrail.set(key, ring)
|
|
6456
|
+
}
|
|
6457
|
+
if (this.trailDue > 0 || ring.pos.length === 0) {
|
|
6458
|
+
const steps = Math.min(this.trailDue, 4)
|
|
6459
|
+
for (let k = 0; k < Math.max(1, steps); k++) {
|
|
6460
|
+
ring.pos.unshift(pos.x, pos.y, pos.z)
|
|
6461
|
+
ring.t.unshift(this.sceneClock)
|
|
6462
|
+
if (ring.t.length > TRAIL_SAMPLES) {
|
|
6463
|
+
ring.t.length = TRAIL_SAMPLES
|
|
6464
|
+
ring.pos.length = TRAIL_SAMPLES * 3
|
|
6465
|
+
}
|
|
6466
|
+
}
|
|
6225
6467
|
}
|
|
6468
|
+
const count = ring.t.length
|
|
6469
|
+
// Age rather than a timestamp: the shader would otherwise need the scene
|
|
6470
|
+
// clock too, and there is only one place that has to know what time it is.
|
|
6471
|
+
const base = (CAST_TRAIL_BASE + (slot * MAX_EFFECT_SUBJECTS + n) * TRAIL_SAMPLES) * 4
|
|
6472
|
+
for (let i = 0; i < count; i++) {
|
|
6473
|
+
cd[base + i * 4] = ring.pos[i * 3]
|
|
6474
|
+
cd[base + i * 4 + 1] = ring.pos[i * 3 + 1]
|
|
6475
|
+
cd[base + i * 4 + 2] = ring.pos[i * 3 + 2]
|
|
6476
|
+
cd[base + i * 4 + 3] = this.sceneClock - ring.t[i]
|
|
6477
|
+
}
|
|
6478
|
+
// The count rides in the anchor's spare lane, so rzTrailCount is one read.
|
|
6479
|
+
cd[anchorBase + 11] = count
|
|
6226
6480
|
}
|
|
6227
6481
|
|
|
6228
6482
|
private updateSkinMatrices() {
|
package/src/model.ts
CHANGED
|
@@ -968,6 +968,23 @@ export class Model {
|
|
|
968
968
|
return this.runtimeSkeleton.worldMatrices[idx].getPosition()
|
|
969
969
|
}
|
|
970
970
|
|
|
971
|
+
/**
|
|
972
|
+
* A bone's forward axis, normalised — which way a foot points, where a head
|
|
973
|
+
* looks. Model space, like getBoneWorldPosition: the caller composes the model
|
|
974
|
+
* transform on top if it wants world space.
|
|
975
|
+
*
|
|
976
|
+
* Column 2 of the world matrix. Null for a name this rig does not have, which
|
|
977
|
+
* is the ordinary case across rigs that spell bones differently.
|
|
978
|
+
*/
|
|
979
|
+
getBoneWorldForward(boneName: string): Vec3 | null {
|
|
980
|
+
const idx = this.runtimeSkeleton.nameIndex[boneName]
|
|
981
|
+
if (idx === undefined || idx < 0) return null
|
|
982
|
+
const m = this.runtimeSkeleton.worldMatrices[idx].values
|
|
983
|
+
const len = Math.hypot(m[8], m[9], m[10])
|
|
984
|
+
if (len < 1e-8) return null
|
|
985
|
+
return new Vec3(m[8] / len, m[9] / len, m[10] / len)
|
|
986
|
+
}
|
|
987
|
+
|
|
971
988
|
getSkinning(): Skinning {
|
|
972
989
|
return this.skinning
|
|
973
990
|
}
|
|
@@ -29,7 +29,15 @@
|
|
|
29
29
|
* Compare a particle's own distance against it and the model
|
|
30
30
|
* occludes it; fog needs no comparison at all, its alpha simply IS
|
|
31
31
|
* a function of distance.
|
|
32
|
-
* - `
|
|
32
|
+
* - `rzResolution()` — canvas size in pixels, for aspect correction.
|
|
33
|
+
* - `rzCameraPos()`, `rzWorldPos(ray, depth)` — the lens, and the place a pixel
|
|
34
|
+
* was drawn.
|
|
35
|
+
* - `rzSubjectCount()`, `rzSubjectHip(i)` — the cast, at HIP height (see the
|
|
36
|
+
* function; it is not the floor, and reading it as the floor is a
|
|
37
|
+
* mistake this API's own comment used to invite).
|
|
38
|
+
* - `rzProject(p)` — a world point as uv + view-axis distance; the cheap way to
|
|
39
|
+
* anchor anything, and `z` compares directly against `depth`.
|
|
40
|
+
* - the bg* spellings of all of the above still resolve, permanently.
|
|
33
41
|
* - declared params arrive as `params.<name>` (f32 or vec3f), shared by both.
|
|
34
42
|
*
|
|
35
43
|
* Return display-space sRGB + alpha, 0..1. Both mounts are alpha-composited
|
|
@@ -38,6 +46,46 @@
|
|
|
38
46
|
* 0 lets it through, which is how a starfield is stars over the user's color;
|
|
39
47
|
* a foreground at alpha 1 covers the frame. No mode flag anywhere — the alpha
|
|
40
48
|
* channel already says it. */
|
|
49
|
+
/**
|
|
50
|
+
* The bones an effect asked for, in declaration order — the slots rzAnchor reads.
|
|
51
|
+
*
|
|
52
|
+
* // @anchor 左手首 trail
|
|
53
|
+
* // @anchor 頭
|
|
54
|
+
*
|
|
55
|
+
* A declaration in the source, like the mounts: what a file names is what gets
|
|
56
|
+
* resolved and uploaded, so naming none costs nothing and nobody pays for a
|
|
57
|
+
* rig's other five hundred bones. Anchored to the start of a line so that
|
|
58
|
+
* writing the word @anchor in ordinary prose does not silently add a slot —
|
|
59
|
+
* which would shift every slot after it.
|
|
60
|
+
*
|
|
61
|
+
* `trail` additionally keeps that bone's recent PATH, for rzTrail. Opt-in
|
|
62
|
+
* because a path is two orders of magnitude more data than a point, and most
|
|
63
|
+
* anchors want a point.
|
|
64
|
+
*
|
|
65
|
+
* Names are passed through verbatim: any bone the rig has works, and one it does
|
|
66
|
+
* not have simply reports invalid.
|
|
67
|
+
*/
|
|
68
|
+
export function parseEffectAnchors(wgsl: string, max: number): { bone: string; trail: boolean }[] {
|
|
69
|
+
return [...wgsl.matchAll(/^[ \t]*\/\/[ \t]*@anchor[ \t]+(\S+)([ \t]+trail)?[ \t]*$/gm)]
|
|
70
|
+
.map((m) => ({ bone: m[1], trail: m[2] !== undefined }))
|
|
71
|
+
.slice(0, max)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The caps the cast buffer is built to, shared by the shader below and by the
|
|
76
|
+
* engine that fills it. Interpolated into the WGSL rather than written twice:
|
|
77
|
+
* the layout arithmetic on both sides has to agree exactly, and two literals
|
|
78
|
+
* that must match are two literals that eventually will not.
|
|
79
|
+
*
|
|
80
|
+
* All three are MINIMUMS. Raising one breaks nothing, because effects read
|
|
81
|
+
* through accessors and loop to the count functions; lowering one does.
|
|
82
|
+
*/
|
|
83
|
+
export const EFFECT_SUBJECTS = 4
|
|
84
|
+
export const EFFECT_ANCHORS = 8
|
|
85
|
+
export const EFFECT_TRAIL_SAMPLES = 128
|
|
86
|
+
/** vec4 slot where the trails begin — after the subjects and the anchors. */
|
|
87
|
+
export const EFFECT_TRAIL_BASE = EFFECT_SUBJECTS * 3 + EFFECT_ANCHORS * EFFECT_SUBJECTS * 3
|
|
88
|
+
|
|
41
89
|
export type CompositeEffectSource = {
|
|
42
90
|
/** The user's WGSL verbatim: helpers plus whichever entry points it defines. */
|
|
43
91
|
wgsl: string
|
|
@@ -107,6 +155,13 @@ override APPLY_GAMMA: bool = true;
|
|
|
107
155
|
// Blender's AgX, as the 57³ lookup it ships as rather than a reconstruction of
|
|
108
156
|
// it. Sampled in the log-encoded E-Gamut space the cube expects — see agxTransform.
|
|
109
157
|
@group(0) @binding(10) var agxLut: texture_3d<f32>;
|
|
158
|
+
// The cast, as data. Read through rzSubject/rzAnchor below — the LAYOUT IS NOT
|
|
159
|
+
// STABLE and never will be, because it depends on what each effect declared.
|
|
160
|
+
// Reading it directly is the one thing that would freeze it forever.
|
|
161
|
+
//
|
|
162
|
+
// vec4 slots: [0 .. 11] four subjects, three each (root+valid, hip, bounds);
|
|
163
|
+
// then MAX_ANCHORS × four subjects, three each (pos+valid, vel, fwd).
|
|
164
|
+
@group(0) @binding(11) var<storage, read> _rzCast: array<vec4f>;
|
|
110
165
|
|
|
111
166
|
// Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
|
|
112
167
|
const FILMIC_LUT_W: f32 = 256.0;
|
|
@@ -202,29 +257,179 @@ fn viewTransform(c: vec3f) -> vec3f {
|
|
|
202
257
|
return vec3f(filmic(c.r), filmic(c.g), filmic(c.b));
|
|
203
258
|
}
|
|
204
259
|
|
|
205
|
-
|
|
206
|
-
|
|
260
|
+
// ── The effect API ────────────────────────────────────────────────────────────
|
|
261
|
+
//
|
|
262
|
+
// Named rz*, for the engine. The prefix earns its place twice: user code is
|
|
263
|
+
// concatenated into THIS module, so an unprefixed rzAnchor() would collide with
|
|
264
|
+
// exactly the helper an author would write, and the old bg* prefix stopped being
|
|
265
|
+
// true in 0.41.0 when effects gained a mount over the finished frame.
|
|
266
|
+
//
|
|
267
|
+
// The bg* names below are permanent aliases, not a deprecation with an end date.
|
|
268
|
+
// A published link is immutable, so a scene pinned to a bg* effect has to keep
|
|
269
|
+
// compiling forever. They are one-line and inlined; no new function gets one.
|
|
270
|
+
|
|
271
|
+
/** Canvas size in pixels — for aspect correction. */
|
|
272
|
+
fn rzResolution() -> vec2f { return viewU[6].zw; }
|
|
207
273
|
|
|
208
274
|
/** The camera's world position. */
|
|
209
|
-
fn
|
|
275
|
+
fn rzCameraPos() -> vec3f { return viewU[10].xyz; }
|
|
210
276
|
|
|
211
277
|
/** How many characters are in the scene, up to four. */
|
|
212
|
-
fn
|
|
278
|
+
fn rzSubjectCount() -> i32 { return i32(viewU[10].w); }
|
|
213
279
|
|
|
214
280
|
/**
|
|
215
|
-
*
|
|
281
|
+
* A world point as the camera sees it: xy the uv it lands on, z its distance
|
|
282
|
+
* along the VIEW AXIS in metres.
|
|
216
283
|
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
284
|
+
* The exact inverse of the ray this pass builds per pixel, so it is the cheap way
|
|
285
|
+
* to work with anything anchored in the world. Marching a curve or a trail in 3D
|
|
286
|
+
* costs a distance evaluation per sample per pixel; projecting its points once
|
|
287
|
+
* and measuring in 2D costs a subtraction, which is the difference between a
|
|
288
|
+
* ribbon that runs at 4K and one that does not.
|
|
289
|
+
*
|
|
290
|
+
* z is directly comparable to the depth handed to foreground(), so occlusion is
|
|
291
|
+
* a single test: draw where your z is nearer than the scene's. It is returned
|
|
292
|
+
* SIGNED and unclamped — behind the camera is negative, and worth rejecting
|
|
293
|
+
* before you use the uv, which is meaningless there.
|
|
294
|
+
*/
|
|
295
|
+
fn rzProject(p: vec3f) -> vec3f {
|
|
296
|
+
let d = p - viewU[10].xyz;
|
|
297
|
+
let z = dot(d, viewU[5].xyz);
|
|
298
|
+
// Guard only the divide. z itself is returned as it is, so the caller can see
|
|
299
|
+
// the sign; clamping it here would put points behind the lens on the horizon.
|
|
300
|
+
let inv = 1.0 / select(z, 1e-4, z < 1e-4);
|
|
301
|
+
let ndc = vec2f(dot(d, viewU[3].xyz) * inv / viewU[3].w, dot(d, viewU[4].xyz) * inv / viewU[4].w);
|
|
302
|
+
return vec3f(ndc * 0.5 + 0.5, z);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A character, as much of one as a shader needs. */
|
|
306
|
+
struct RzSubject {
|
|
307
|
+
/** On the FLOOR, under the body — where a ring or a magic circle belongs. */
|
|
308
|
+
root: vec3f,
|
|
309
|
+
/** At the hips, the middle of the body — where an aura belongs. */
|
|
310
|
+
center: vec3f,
|
|
311
|
+
/** Bounding sphere: xyz centre, w radius. Deliberately generous — cull with it. */
|
|
312
|
+
bounds: vec4f,
|
|
313
|
+
/** False past the end of the cast, and every field is then zero. */
|
|
314
|
+
valid: bool,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** One bone an effect asked for, by name, at the top of its own source. */
|
|
318
|
+
struct RzAnchor {
|
|
319
|
+
pos: vec3f,
|
|
320
|
+
/** World units per second, from the previous frame. Direction for a trail,
|
|
321
|
+
* magnitude for anything that should react to how hard someone is moving. */
|
|
322
|
+
vel: vec3f,
|
|
323
|
+
/** The bone's forward axis — which way a foot points, where a head looks. */
|
|
324
|
+
fwd: vec3f,
|
|
325
|
+
/** False when this rig has no such bone. Check it: the alternative is drawing
|
|
326
|
+
* a hand effect at the world origin on every model that spells it differently. */
|
|
327
|
+
valid: bool,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const RZ_MAX_ANCHORS: i32 = ${EFFECT_ANCHORS};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Character i. Loop to rzSubjectCount(), never to a constant — the caps here are
|
|
334
|
+
* MINIMUMS and are free to grow, which is only true while nobody hardcodes them.
|
|
335
|
+
*/
|
|
336
|
+
fn rzSubject(i: i32) -> RzSubject {
|
|
337
|
+
var s: RzSubject;
|
|
338
|
+
s.valid = i >= 0 && i < rzSubjectCount();
|
|
339
|
+
if (!s.valid) { return s; }
|
|
340
|
+
let b = i * 3;
|
|
341
|
+
s.root = _rzCast[b].xyz;
|
|
342
|
+
s.center = _rzCast[b + 1].xyz;
|
|
343
|
+
s.bounds = _rzCast[b + 2];
|
|
344
|
+
return s;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The slot-th bone this effect declared, on character subject.
|
|
349
|
+
*
|
|
350
|
+
* Slots are the order of the declarations at the top of your source:
|
|
351
|
+
*
|
|
352
|
+
* // @anchor 左手首
|
|
353
|
+
* // @anchor 頭
|
|
354
|
+
*
|
|
355
|
+
* gives you slot 0 and slot 1. Any bone name the model has works; valid is
|
|
356
|
+
* false when it does not have it, which is the normal case across rigs that
|
|
357
|
+
* spell things differently.
|
|
358
|
+
*/
|
|
359
|
+
fn rzAnchor(subject: i32, slot: i32) -> RzAnchor {
|
|
360
|
+
var a: RzAnchor;
|
|
361
|
+
a.valid = false;
|
|
362
|
+
if (subject < 0 || subject >= rzSubjectCount() || slot < 0 || slot >= RZ_MAX_ANCHORS) { return a; }
|
|
363
|
+
let b = ${EFFECT_SUBJECTS * 3} + (slot * ${EFFECT_SUBJECTS} + subject) * 3;
|
|
364
|
+
a.valid = _rzCast[b].w > 0.5;
|
|
365
|
+
a.pos = _rzCast[b].xyz;
|
|
366
|
+
a.vel = _rzCast[b + 1].xyz;
|
|
367
|
+
a.fwd = _rzCast[b + 2].xyz;
|
|
368
|
+
return a;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const RZ_TRAIL_SAMPLES: i32 = ${EFFECT_TRAIL_SAMPLES};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* How many path samples this anchor has. Zero unless it was declared with
|
|
375
|
+
* trail, and it climbs from zero as the trail fills after the effect loads.
|
|
376
|
+
*
|
|
377
|
+
* Loop to THIS, never to RZ_TRAIL_SAMPLES: the cap is a minimum and is free to
|
|
378
|
+
* grow, which stays true only while nobody hardcodes it.
|
|
379
|
+
*/
|
|
380
|
+
fn rzTrailCount(subject: i32, slot: i32) -> i32 {
|
|
381
|
+
if (subject < 0 || subject >= rzSubjectCount() || slot < 0 || slot >= RZ_MAX_ANCHORS) { return 0; }
|
|
382
|
+
return i32(_rzCast[${EFFECT_SUBJECTS * 3} + (slot * ${EFFECT_SUBJECTS} + subject) * 3 + 2].w);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Sample i of an anchor's path: xyz where it was, w how many seconds ago.
|
|
387
|
+
*
|
|
388
|
+
* i = 0 is NOW and they run backwards in time, so a ribbon is drawn by walking i
|
|
389
|
+
* upward and fading on .w. Sampled at a fixed rate on the SCENE clock, not the
|
|
390
|
+
* display's — so the path is identical in the editor, in an export, and in a
|
|
391
|
+
* re-export, and its spacing does not change with framerate.
|
|
392
|
+
*
|
|
393
|
+
* This is what a hand trail wants instead of position and velocity. One position
|
|
394
|
+
* and one velocity is a straight segment that jitters, because a velocity is a
|
|
395
|
+
* difference between two frames; a path is what actually happened.
|
|
396
|
+
*/
|
|
397
|
+
fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
|
|
398
|
+
let n = rzTrailCount(subject, slot);
|
|
399
|
+
if (i < 0 || i >= n) { return vec4f(0.0); }
|
|
400
|
+
let base = ${EFFECT_TRAIL_BASE} + (slot * ${EFFECT_SUBJECTS} + subject) * RZ_TRAIL_SAMPLES;
|
|
401
|
+
return _rzCast[base + i];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
fn bgResolution() -> vec2f { return rzResolution(); }
|
|
405
|
+
fn bgCameraPos() -> vec3f { return rzCameraPos(); }
|
|
406
|
+
fn bgSubjectCount() -> i32 { return rzSubjectCount(); }
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Where a character IS, in world space — at the hips, not on the floor.
|
|
410
|
+
*
|
|
411
|
+
* An effect that wants to RESPOND to the cast — a glow that follows someone,
|
|
412
|
+
* dust kicked up where they are — needs to know where they are, and the ray and
|
|
413
|
+
* the depth cannot tell it: they describe the pixel, not the scene.
|
|
414
|
+
*
|
|
415
|
+
* The value is model.position + センター + 全ての親. センター sits at hip
|
|
416
|
+
* height on every standard MMD rig, so this is a point in the middle of the
|
|
417
|
+
* body. It is NOT the contact point: a ripple drawn here appears at the waist.
|
|
418
|
+
* Ground effects want the .xz of this and their own floor height, which is what
|
|
419
|
+
* the effects that shipped against it already do.
|
|
420
|
+
*
|
|
421
|
+
* The comment here used to claim it was "between the feet on the floor", which
|
|
422
|
+
* is where that habit came from. Left as it is regardless of the name: a
|
|
423
|
+
* published link is immutable, so every shared scene pinning an effect that
|
|
424
|
+
* reads this depends on it meaning exactly what it has always meant.
|
|
222
425
|
*
|
|
223
426
|
* Clamped rather than bounds-checked: an effect looping past the count reads the
|
|
224
427
|
* last subject instead of sampling whatever follows the array, which is a wrong
|
|
225
428
|
* ripple rather than an undefined one.
|
|
226
429
|
*/
|
|
227
|
-
fn
|
|
430
|
+
fn rzSubjectHip(i: i32) -> vec3f { return viewU[11 + clamp(i, 0, 3)].xyz; }
|
|
431
|
+
|
|
432
|
+
fn bgSubjectPos(i: i32) -> vec3f { return rzSubjectHip(i); }
|
|
228
433
|
|
|
229
434
|
/** Where in the WORLD the scene drew this pixel — the depth handed to
|
|
230
435
|
* foreground() turned into a place. Without it an effect can only think in
|
|
@@ -235,11 +440,13 @@ fn bgSubjectPos(i: i32) -> vec3f { return viewU[11 + clamp(i, 0, 3)].xyz; }
|
|
|
235
440
|
* the ray's projection onto camera-forward before being walked out. At the far
|
|
236
441
|
* plane (nothing drawn) this lands a very long way off, which is what a sky
|
|
237
442
|
* should do to anything reading it. */
|
|
238
|
-
fn
|
|
443
|
+
fn rzWorldPos(ray: vec3f, depth: f32) -> vec3f {
|
|
239
444
|
let axis = max(dot(normalize(ray), viewU[5].xyz), 1e-4);
|
|
240
|
-
return
|
|
445
|
+
return rzCameraPos() + normalize(ray) * (depth / axis);
|
|
241
446
|
}
|
|
242
447
|
|
|
448
|
+
fn bgWorldPos(ray: vec3f, depth: f32) -> vec3f { return rzWorldPos(ray, depth); }
|
|
449
|
+
|
|
243
450
|
/** Color grading, applied to the tonemapped SCENE (not the background — see the
|
|
244
451
|
* call site). The core is ASC CDL, the film-industry interchange standard:
|
|
245
452
|
*
|