reze-engine 0.41.3 → 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 +42 -3
- package/dist/animation.d.ts +18 -0
- package/dist/animation.d.ts.map +1 -1
- package/dist/animation.js +26 -16
- package/dist/engine.d.ts +45 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +286 -11
- package/dist/model.d.ts +56 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +125 -22
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +8 -5
- 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/animation.ts +26 -16
- package/src/engine.ts +301 -10
- package/src/model.ts +133 -22
- package/src/pmx-loader.ts +10 -5
- package/src/shaders/passes/composite.ts +221 -14
package/package.json
CHANGED
package/src/animation.ts
CHANGED
|
@@ -355,24 +355,34 @@ export function bezierInterpolate(x1: number, x2: number, y1: number, y2: number
|
|
|
355
355
|
|
|
356
356
|
const INV_127 = 1 / 127
|
|
357
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The 64 interpolation bytes of a VMD bone frame, as four bezier curves.
|
|
360
|
+
*
|
|
361
|
+
* MMD interleaves the channels rather than storing them one after another: byte
|
|
362
|
+
* `i` is channel i's x1, `i + 4` its y1, `i + 8` its x2, `i + 12` its y2, where
|
|
363
|
+
* the channels are X = 0, Y = 1, Z = 2 and ROTATION = 3. The remaining 48 bytes
|
|
364
|
+
* are the same record written three more times, each shifted a byte left — a
|
|
365
|
+
* legacy quirk, and not one to read from: real files in this repo disagree with
|
|
366
|
+
* their own shifted copies, so the first block is the only trustworthy one.
|
|
367
|
+
*
|
|
368
|
+
* Rotation used to read `raw[0..3]`, which is not rotation's curve at all — it
|
|
369
|
+
* is the x1 byte of all four channels in a row. On an ordinary keyframe that
|
|
370
|
+
* evaluates to a bezier with both control points at y = 0: the curve holds near
|
|
371
|
+
* zero for most of the interval and then snaps to 1 at its end. Applied to every
|
|
372
|
+
* bone's rotation on every keyframe interval, which is essentially all of an MMD
|
|
373
|
+
* motion, it reads as a dance that stutters between poses instead of flowing
|
|
374
|
+
* through them.
|
|
375
|
+
*/
|
|
358
376
|
export function rawInterpolationToBoneInterpolation(raw: Uint8Array): BoneInterpolation {
|
|
377
|
+
const channel = (i: number): ControlPoint[] => [
|
|
378
|
+
{ x: raw[i], y: raw[i + 4] },
|
|
379
|
+
{ x: raw[i + 8], y: raw[i + 12] },
|
|
380
|
+
]
|
|
359
381
|
return {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
translationX: [
|
|
365
|
-
{ x: raw[0], y: raw[4] },
|
|
366
|
-
{ x: raw[8], y: raw[12] },
|
|
367
|
-
],
|
|
368
|
-
translationY: [
|
|
369
|
-
{ x: raw[16], y: raw[20] },
|
|
370
|
-
{ x: raw[24], y: raw[28] },
|
|
371
|
-
],
|
|
372
|
-
translationZ: [
|
|
373
|
-
{ x: raw[32], y: raw[36] },
|
|
374
|
-
{ x: raw[40], y: raw[44] },
|
|
375
|
-
],
|
|
382
|
+
translationX: channel(0),
|
|
383
|
+
translationY: channel(1),
|
|
384
|
+
translationZ: channel(2),
|
|
385
|
+
rotation: channel(3),
|
|
376
386
|
}
|
|
377
387
|
}
|
|
378
388
|
|
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
|
|
@@ -648,6 +684,9 @@ function materialAlphaStats(
|
|
|
648
684
|
return { avg: sum / n / 255, translucentFrac: translucent / n }
|
|
649
685
|
}
|
|
650
686
|
|
|
687
|
+
/** Tried in order when a PMX names a texture without an extension. */
|
|
688
|
+
const TEXTURE_EXTENSION_GUESSES = [".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds", ".spa", ".sph"]
|
|
689
|
+
|
|
651
690
|
export class Engine {
|
|
652
691
|
private static instance: Engine | null = null
|
|
653
692
|
|
|
@@ -837,7 +876,29 @@ export class Engine {
|
|
|
837
876
|
/** Mounted over the finished frame — and the reason the scene pass has to
|
|
838
877
|
* STORE its depth, which it otherwise discards into tile memory. */
|
|
839
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 }[]
|
|
840
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
|
|
841
902
|
private agxLutTexture: GPUTexture | null = null
|
|
842
903
|
private agxFallbackTexture!: GPUTexture
|
|
843
904
|
/** Bound at composite binding 7 when no effect (or a param-less one) is set. */
|
|
@@ -1188,6 +1249,7 @@ export class Engine {
|
|
|
1188
1249
|
|
|
1189
1250
|
private rebuildCompositeBindGroup(): void {
|
|
1190
1251
|
if (!this.device || !this.hdrResolveTexture || !this.compositeBloomView || !this.depthReadView) return
|
|
1252
|
+
if (!this.castBuffer) return
|
|
1191
1253
|
this.compositeBindGroup = this.device.createBindGroup({
|
|
1192
1254
|
label: "composite bind group",
|
|
1193
1255
|
layout: this.compositeBindGroupLayout,
|
|
@@ -1203,6 +1265,7 @@ export class Engine {
|
|
|
1203
1265
|
{ binding: 8, resource: this.depthReadView },
|
|
1204
1266
|
{ binding: 9, resource: { buffer: this.dofUniformBuffer } },
|
|
1205
1267
|
{ binding: 10, resource: (this.agxLutTexture ?? this.agxFallbackTexture).createView({ dimension: "3d" }) },
|
|
1268
|
+
{ binding: 11, resource: { buffer: this.castBuffer } },
|
|
1206
1269
|
],
|
|
1207
1270
|
})
|
|
1208
1271
|
}
|
|
@@ -1334,6 +1397,14 @@ export class Engine {
|
|
|
1334
1397
|
}
|
|
1335
1398
|
const mounts = { background: hasBackground, foreground: hasForeground }
|
|
1336
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
|
+
|
|
1337
1408
|
// ── Params: codegen a WGSL struct and mirror its uniform layout on the CPU.
|
|
1338
1409
|
// Fields are emitted in declaration order; offsets follow WGSL's natural
|
|
1339
1410
|
// uniform rules (f32 align 4, vec3f align 16 size 12), computed identically
|
|
@@ -1415,7 +1486,13 @@ export class Engine {
|
|
|
1415
1486
|
})
|
|
1416
1487
|
this.device.queue.writeBuffer(paramsBuffer, 0, paramsData)
|
|
1417
1488
|
}
|
|
1418
|
-
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()
|
|
1419
1496
|
this.compositePipelineIdentity = identity
|
|
1420
1497
|
this.compositePipelineGamma = gamma
|
|
1421
1498
|
this.effectEpochMs = performance.now()
|
|
@@ -2454,6 +2531,15 @@ export class Engine {
|
|
|
2454
2531
|
size: 16,
|
|
2455
2532
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2456
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
|
+
})
|
|
2457
2543
|
this.compositeBindGroupLayout = this.device.createBindGroupLayout({
|
|
2458
2544
|
label: "composite bind group layout",
|
|
2459
2545
|
entries: [
|
|
@@ -2483,6 +2569,9 @@ export class Engine {
|
|
|
2483
2569
|
// AgX's 57³ cube. Decompressed and uploaded off the critical path, so a
|
|
2484
2570
|
// 1×1×1 stand-in keeps the bind group valid until it arrives.
|
|
2485
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" } },
|
|
2486
2575
|
],
|
|
2487
2576
|
})
|
|
2488
2577
|
this.fallbackEquirectTexture = this.device.createTexture({
|
|
@@ -3174,13 +3263,21 @@ export class Engine {
|
|
|
3174
3263
|
// so a static stage in the scene never freezes the shot at frame 0. Falls back to the first
|
|
3175
3264
|
// model, then to 0 (empty scene).
|
|
3176
3265
|
private cameraClockTime(): number {
|
|
3177
|
-
let
|
|
3266
|
+
let fallback: number | null = null
|
|
3178
3267
|
for (const inst of this.modelInstances.values()) {
|
|
3268
|
+
// Stages are skipped outright. Scenery carries no motion, and it is added
|
|
3269
|
+
// BEFORE the cast — it paints while the models stream in behind it — so it
|
|
3270
|
+
// is first in insertion order and was seeding this clock with its own
|
|
3271
|
+
// permanent zero. In a scene with a stage, a camera VMD therefore sampled
|
|
3272
|
+
// frame 0 forever and the shot never moved.
|
|
3273
|
+
if (inst.isStage) continue
|
|
3179
3274
|
const p = inst.model.getAnimationProgress()
|
|
3180
|
-
if (first === null) first = p.current
|
|
3181
3275
|
if (p.playing || p.paused) return p.current
|
|
3276
|
+
// Otherwise the first cast member that actually HAS a clip: one still at
|
|
3277
|
+
// bind pose must not claim the clock from one holding the motion.
|
|
3278
|
+
if (fallback === null && p.duration > 0) fallback = p.current
|
|
3182
3279
|
}
|
|
3183
|
-
return
|
|
3280
|
+
return fallback ?? 0
|
|
3184
3281
|
}
|
|
3185
3282
|
|
|
3186
3283
|
/** Current orbit eye position (spherical coords resolved to a point). */
|
|
@@ -4627,12 +4724,38 @@ export class Engine {
|
|
|
4627
4724
|
return cached
|
|
4628
4725
|
}
|
|
4629
4726
|
|
|
4630
|
-
|
|
4727
|
+
// PMX texture tables are hand-maintained, and they routinely carry entries
|
|
4728
|
+
// that are not files. Two kinds show up constantly: a bare directory
|
|
4729
|
+
// ("Textures", "spa\\"), which is a leftover placeholder pointing at nothing,
|
|
4730
|
+
// and a name whose extension was dropped — where the texture is sitting right
|
|
4731
|
+
// there on disk one suffix longer, and the material renders white for want of
|
|
4732
|
+
// it. The first is answered by staying quiet, the second by trying.
|
|
4733
|
+
let buffer: ArrayBuffer | null = null
|
|
4734
|
+
let readError: unknown = null
|
|
4631
4735
|
try {
|
|
4632
4736
|
buffer = await inst.assetReader.readBinary(logicalPath)
|
|
4633
4737
|
} catch (e) {
|
|
4634
|
-
|
|
4635
|
-
|
|
4738
|
+
readError = e
|
|
4739
|
+
}
|
|
4740
|
+
if (!buffer) {
|
|
4741
|
+
const base = logicalPath.split(/[\\/]/).pop() ?? ""
|
|
4742
|
+
// No basename at all: the entry named a directory. Nothing was ever meant
|
|
4743
|
+
// to load, so this is not a failure worth a line in anyone's console.
|
|
4744
|
+
if (!base) return null
|
|
4745
|
+
if (!base.includes(".")) {
|
|
4746
|
+
for (const ext of TEXTURE_EXTENSION_GUESSES) {
|
|
4747
|
+
try {
|
|
4748
|
+
buffer = await inst.assetReader.readBinary(`${logicalPath}${ext}`)
|
|
4749
|
+
break
|
|
4750
|
+
} catch {
|
|
4751
|
+
// keep trying — the list is short and only runs for a broken entry
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
}
|
|
4755
|
+
if (!buffer) {
|
|
4756
|
+
console.warn(`[reze] texture read failed: ${logicalPath}`, readError instanceof Error ? readError.message : readError)
|
|
4757
|
+
return null
|
|
4758
|
+
}
|
|
4636
4759
|
}
|
|
4637
4760
|
|
|
4638
4761
|
// Decode to either an ImageBitmap (web-native formats) or raw RGBA (TGA, DDS, PSD).
|
|
@@ -5356,6 +5479,13 @@ export class Engine {
|
|
|
5356
5479
|
}
|
|
5357
5480
|
|
|
5358
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
|
|
5359
5489
|
const tFrame = performance.now()
|
|
5360
5490
|
this.frameAnimMsRaw = 0
|
|
5361
5491
|
this.framePhysicsMsRaw = 0
|
|
@@ -6181,11 +6311,172 @@ export class Engine {
|
|
|
6181
6311
|
u[44 + n * 4] = px
|
|
6182
6312
|
u[45 + n * 4] = py
|
|
6183
6313
|
u[46 + n * 4] = pz
|
|
6314
|
+
this.writeCastEntry(inst, n, px, py, pz)
|
|
6184
6315
|
n++
|
|
6185
6316
|
})
|
|
6186
6317
|
u[43] = n
|
|
6187
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
|
+
}
|
|
6188
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
|
|
6189
6480
|
}
|
|
6190
6481
|
|
|
6191
6482
|
private updateSkinMatrices() {
|