reze-engine 0.50.12 → 0.51.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/engine.d.ts +81 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +295 -46
- package/dist/graph/presets/unlit.d.ts +3 -0
- package/dist/graph/presets/unlit.d.ts.map +1 -0
- package/dist/graph/presets/unlit.js +34 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/shaders/lyrics-api.d.ts +11 -2
- package/dist/shaders/lyrics-api.d.ts.map +1 -1
- package/dist/shaders/lyrics-api.js +11 -2
- package/package.json +1 -1
- package/src/engine.ts +315 -11
- package/src/graph/presets/unlit.ts +37 -0
- package/src/index.ts +1 -0
- package/src/shaders/lyrics-api.ts +11 -2
package/src/engine.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Camera } from "./camera"
|
|
|
2
2
|
import { decodeDds, isDds } from "./dds-loader"
|
|
3
3
|
import { Mat4, Quat, Vec3 } from "./math"
|
|
4
4
|
import { decodePsd, isPsd } from "./psd-loader"
|
|
5
|
-
import { Model, MATERIAL_MORPH_MULTIPLY, type Material } from "./model"
|
|
5
|
+
import { Model, MATERIAL_MORPH_MULTIPLY, type Material, type Skeleton } from "./model"
|
|
6
6
|
import { MORPH_COMPUTE_WGSL } from "./shaders/passes/morph"
|
|
7
7
|
import { CULL_COMPUTE_WGSL } from "./shaders/passes/cull"
|
|
8
8
|
import { buildAnchorTable, anchorAliasWgsl, EMPTY_ANCHOR_TABLE, type AnchorTable } from "./shaders/anchor-table"
|
|
@@ -101,6 +101,7 @@ import type {
|
|
|
101
101
|
StyleGroup,
|
|
102
102
|
} from "./graph/style-group"
|
|
103
103
|
import { DEFAULT_GRAPH } from "./graph/presets/default"
|
|
104
|
+
import { UNLIT_GRAPH } from "./graph/presets/unlit"
|
|
104
105
|
import { FACE_GRAPH } from "./graph/presets/face"
|
|
105
106
|
import { HAIR_GRAPH } from "./graph/presets/hair"
|
|
106
107
|
import { BODY_GRAPH } from "./graph/presets/body"
|
|
@@ -719,6 +720,21 @@ interface ModelInstance {
|
|
|
719
720
|
/** Environment geometry added via addStage — no physics, no IK, and it
|
|
720
721
|
* suppresses the built-in ground. See addStage for why each of those. */
|
|
721
722
|
isStage: boolean
|
|
723
|
+
/**
|
|
724
|
+
* A media plane: a flat card carrying a picture.
|
|
725
|
+
*
|
|
726
|
+
* Its OWN flag rather than a shade of isStage. The two overlap in what they
|
|
727
|
+
* skip — neither performs, so neither wants physics, IK, the cast buffer or
|
|
728
|
+
* the camera clock — but they disagree on the thing a stage exists for: a
|
|
729
|
+
* stage IS the floor and suppresses the built-in ground, while a card is
|
|
730
|
+
* scenery standing in the scene and must leave the floor alone. Folding a
|
|
731
|
+
* plane into isStage would have made adding a title graphic delete the ground.
|
|
732
|
+
*/
|
|
733
|
+
isPlane: boolean
|
|
734
|
+
/** This card's texture is rewritten every frame, so it is allocated with no
|
|
735
|
+
* mip chain — rebuilding one per frame is a pass per level per card, and is
|
|
736
|
+
* what a moving card was mostly costing. See setPlaneFrame. */
|
|
737
|
+
dynamicTexture: boolean
|
|
722
738
|
/** A pose pass ran since the last skin-matrix upload. Always true for cast
|
|
723
739
|
* members; false for an idle stage, which is the point. */
|
|
724
740
|
skinMatricesDirty: boolean
|
|
@@ -2994,6 +3010,32 @@ export class Engine {
|
|
|
2994
3010
|
* is KEPT and diagnostics are returned with line numbers relative to the
|
|
2995
3011
|
* user's WGSL. Pass null to remove the effect.
|
|
2996
3012
|
*/
|
|
3013
|
+
/**
|
|
3014
|
+
* Every directive an effect may declare — used ONLY to tell an author that
|
|
3015
|
+
* the line they wrote is not doing what they think. See compileEffect.
|
|
3016
|
+
*
|
|
3017
|
+
* It has to be complete, including the ones this engine does not read itself:
|
|
3018
|
+
* `@dissolve` is parsed by the host, and warning about it would be worse than
|
|
3019
|
+
* the silence this replaced — a false alarm on a working line teaches authors
|
|
3020
|
+
* to ignore the channel.
|
|
3021
|
+
*/
|
|
3022
|
+
private static readonly KNOWN = new Set([
|
|
3023
|
+
"@anchor",
|
|
3024
|
+
"@layer",
|
|
3025
|
+
"@blend",
|
|
3026
|
+
"@bloom",
|
|
3027
|
+
"@lights",
|
|
3028
|
+
"@grid",
|
|
3029
|
+
"@particles",
|
|
3030
|
+
"@halfres",
|
|
3031
|
+
// Accepted and inert: full resolution is the default now, and an effect
|
|
3032
|
+
// that still says so is right about what it wants. Warning about it would
|
|
3033
|
+
// be telling authors off for the thing that used to be necessary.
|
|
3034
|
+
"@fullres",
|
|
3035
|
+
// The HOST's, not this engine's — see the note above.
|
|
3036
|
+
"@dissolve",
|
|
3037
|
+
])
|
|
3038
|
+
|
|
2997
3039
|
private async compileEffect(
|
|
2998
3040
|
wgsl: string,
|
|
2999
3041
|
params: Record<string, EffectParamValue> | undefined,
|
|
@@ -3093,6 +3135,29 @@ export class Engine {
|
|
|
3093
3135
|
// pinning an effect that declares this has to keep installing; saying so is
|
|
3094
3136
|
// all that was ever missing.
|
|
3095
3137
|
const warnings: string[] = []
|
|
3138
|
+
// A DIRECTIVE THAT PARSED AS NOTHING.
|
|
3139
|
+
//
|
|
3140
|
+
// Every pragma is matched with `\s*$` after it, so a line that carries a
|
|
3141
|
+
// note as well — `// @fullres — glyph edges are sub-pixel detail` — matches
|
|
3142
|
+
// none of them and is read as an ordinary comment. Nothing failed, nothing
|
|
3143
|
+
// said anything, and the effect simply ran without the property it asked
|
|
3144
|
+
// for: three shipped effects were silently half-res and a fourth silently
|
|
3145
|
+
// stopped being additive, each one's first line explaining why it needed
|
|
3146
|
+
// the thing it was not getting.
|
|
3147
|
+
//
|
|
3148
|
+
// Every directive this engine knows, so an unrecognised one is named rather
|
|
3149
|
+
// than ignored. Cheap: it runs once per install, over a file a human wrote.
|
|
3150
|
+
for (const m of wgsl.matchAll(/^[ \t]*\/\/[ \t]*(@[a-zA-Z]+)(.*)$/gm)) {
|
|
3151
|
+
const [, tag, rest] = m
|
|
3152
|
+
if (!Engine.KNOWN.has(tag)) {
|
|
3153
|
+
warnings.push(`${tag} is not a directive this engine knows — it will be ignored.`)
|
|
3154
|
+
} else if (rest.trim() && !/^\s*[\w.\-+]+(\s+[\w.\-+]+)*\s*$/.test(rest)) {
|
|
3155
|
+
warnings.push(
|
|
3156
|
+
`${tag} has a note on the same line, so it does not parse and is being IGNORED. ` +
|
|
3157
|
+
`A directive must be alone on its line — put the note on the next one.`,
|
|
3158
|
+
)
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3096
3161
|
if (parseParticleBloom(wgsl) && !wantsParticles && !wantsTrails) {
|
|
3097
3162
|
warnings.push(
|
|
3098
3163
|
"// @bloom does nothing here. A field effect (background/foreground) composites after tone " +
|
|
@@ -3331,7 +3396,22 @@ export class Engine {
|
|
|
3331
3396
|
epochScene: this.sceneClock,
|
|
3332
3397
|
// Its OWN resolution, no longer the scene's: an effect that never asked
|
|
3333
3398
|
// for full res is not promoted because a neighbour did.
|
|
3334
|
-
|
|
3399
|
+
// FULL RESOLUTION UNLESS TOLD OTHERWISE.
|
|
3400
|
+
//
|
|
3401
|
+
// It was the other way round, and the default was the bug. An author
|
|
3402
|
+
// who has never heard of the flag writes an effect with an edge in it
|
|
3403
|
+
// and gets a soft one — nothing fails, nothing warns, because nothing
|
|
3404
|
+
// was declared to fail. Three shipped effects DID declare it and were
|
|
3405
|
+
// half-res anyway on a parsing technicality, which is the same bug
|
|
3406
|
+
// wearing a different hat: the safe answer has to be the one you get
|
|
3407
|
+
// for saying nothing.
|
|
3408
|
+
//
|
|
3409
|
+
// The cost is real and is why the half layer stays: `@halfres` is worth
|
|
3410
|
+
// about 3.7x on a full-screen effect (Footprints, measured, 1.2ms
|
|
3411
|
+
// against 4.5ms). It is the right call for a soft additive glow, which
|
|
3412
|
+
// upsamples invisibly — and it is now a claim an author makes about
|
|
3413
|
+
// their own effect rather than a fate that befalls one.
|
|
3414
|
+
fieldLayer: /^\s*\/\/\s*@halfres\s*$/m.test(wgsl) ? 1 : 0,
|
|
3335
3415
|
fieldPipeline,
|
|
3336
3416
|
fieldClock,
|
|
3337
3417
|
// Filled by rebuildFieldBindGroup below, which needs the instance to
|
|
@@ -4305,7 +4385,9 @@ export class Engine {
|
|
|
4305
4385
|
if (!this.camera) return null
|
|
4306
4386
|
const view = this.camera.getViewMatrix().values
|
|
4307
4387
|
for (const inst of this.modelInstances.values()) {
|
|
4308
|
-
|
|
4388
|
+
// Neither a stage nor a plane is a performer, so neither is a subject an
|
|
4389
|
+
// effect can follow.
|
|
4390
|
+
if (!inst.model.visible || inst.isStage || inst.isPlane) continue
|
|
4309
4391
|
const model = inst.model
|
|
4310
4392
|
const matrices = model.getWorldMatrices()
|
|
4311
4393
|
if (matrices.length === 0) continue
|
|
@@ -6833,7 +6915,7 @@ export class Engine {
|
|
|
6833
6915
|
// is first in insertion order and was seeding this clock with its own
|
|
6834
6916
|
// permanent zero. In a scene with a stage, a camera VMD therefore sampled
|
|
6835
6917
|
// frame 0 forever and the shot never moved.
|
|
6836
|
-
if (inst.isStage) continue
|
|
6918
|
+
if (inst.isStage || inst.isPlane) continue
|
|
6837
6919
|
const p = inst.model.getAnimationProgress()
|
|
6838
6920
|
if (p.playing || p.paused) return p.current
|
|
6839
6921
|
// Otherwise the first cast member that actually HAS a clip: one still at
|
|
@@ -7223,7 +7305,7 @@ export class Engine {
|
|
|
7223
7305
|
pmxPath: string,
|
|
7224
7306
|
name?: string,
|
|
7225
7307
|
assetReader?: AssetReader,
|
|
7226
|
-
options?: { stage?: boolean },
|
|
7308
|
+
options?: { stage?: boolean; plane?: boolean; dynamic?: boolean },
|
|
7227
7309
|
): Promise<string> {
|
|
7228
7310
|
const requested = name ?? model.name
|
|
7229
7311
|
let key = requested
|
|
@@ -7234,7 +7316,15 @@ export class Engine {
|
|
|
7234
7316
|
const reader = assetReader ?? createFetchAssetReader()
|
|
7235
7317
|
const basePath = deriveBasePathFromPmxPath(pmxPath)
|
|
7236
7318
|
model.setAssetContext(reader, basePath)
|
|
7237
|
-
await this.setupModelInstance(
|
|
7319
|
+
await this.setupModelInstance(
|
|
7320
|
+
key,
|
|
7321
|
+
model,
|
|
7322
|
+
basePath,
|
|
7323
|
+
reader,
|
|
7324
|
+
options?.stage ?? false,
|
|
7325
|
+
options?.plane ?? false,
|
|
7326
|
+
options?.dynamic ?? false,
|
|
7327
|
+
)
|
|
7238
7328
|
return key
|
|
7239
7329
|
}
|
|
7240
7330
|
|
|
@@ -7267,6 +7357,191 @@ export class Engine {
|
|
|
7267
7357
|
return key
|
|
7268
7358
|
}
|
|
7269
7359
|
|
|
7360
|
+
/**
|
|
7361
|
+
* Put a picture in the scene as a flat card.
|
|
7362
|
+
*
|
|
7363
|
+
* The thing compositors arrange in a post tool's fake 3D space — Nuke calls
|
|
7364
|
+
* it a Card, After Effects a 3D layer, MMD 板ポリ — except the space here is
|
|
7365
|
+
* the real one. A card is occluded by anything in front of it, occludes what
|
|
7366
|
+
* is behind it, takes perspective when turned, and is caught by depth of
|
|
7367
|
+
* field like everything else, because it is ordinary geometry rather than a
|
|
7368
|
+
* layer composited afterwards.
|
|
7369
|
+
*
|
|
7370
|
+
* It is a MODEL, deliberately. Not a new kind of scene object with its own
|
|
7371
|
+
* list, its own persistence and its own selection: a card wants a position,
|
|
7372
|
+
* a rotation and a size, which is exactly what a model already has, and
|
|
7373
|
+
* everything built around models — the transform, the shadow settings, the
|
|
7374
|
+
* material editor, the asset bundle — works on it the day it exists. It is
|
|
7375
|
+
* not a STAGE, though: it skips the same machinery for the same reasons, and
|
|
7376
|
+
* leaves the floor alone. See ModelInstance.isPlane.
|
|
7377
|
+
*
|
|
7378
|
+
* @returns the model key, for setModelTransform and removeModel.
|
|
7379
|
+
*/
|
|
7380
|
+
async addPlane(options: {
|
|
7381
|
+
/** The picture's own bytes, exactly as uploaded. The name's extension picks
|
|
7382
|
+
* the decoder, so this never re-encodes anything. */
|
|
7383
|
+
image: ArrayBuffer
|
|
7384
|
+
/** File name — decides the decoder, names the model and keys its texture. */
|
|
7385
|
+
name: string
|
|
7386
|
+
/** World size of the card. The caller owns the aspect: it knows the
|
|
7387
|
+
* picture's own proportions, and a card is free to disagree with them. */
|
|
7388
|
+
width: number
|
|
7389
|
+
height: number
|
|
7390
|
+
transform?: Partial<ModelTransform>
|
|
7391
|
+
/** Drawn from behind as well. Off by default — a card turned away from the
|
|
7392
|
+
* camera vanishing is the same thing a sheet of paper does. */
|
|
7393
|
+
doubleSided?: boolean
|
|
7394
|
+
/** The picture will be replaced every frame (see setPlaneFrame). Allocates
|
|
7395
|
+
* the texture without a mip chain, which is what makes that affordable. */
|
|
7396
|
+
dynamic?: boolean
|
|
7397
|
+
}): Promise<string> {
|
|
7398
|
+
const { image, name, width, height } = options
|
|
7399
|
+
const hw = Math.max(width, 1e-4) / 2
|
|
7400
|
+
const hh = Math.max(height, 1e-4) / 2
|
|
7401
|
+
|
|
7402
|
+
// A quad on the XY plane, facing +Z, centred on its own origin — so a
|
|
7403
|
+
// rotation turns it about its middle and a position places its centre,
|
|
7404
|
+
// which is what a handle in the viewport implies.
|
|
7405
|
+
//
|
|
7406
|
+
// V IS FLIPPED, and this is the whole of it: a picture's rows run downward
|
|
7407
|
+
// from its top-left, a UV runs upward from the bottom-left, and a card that
|
|
7408
|
+
// renders its image upside down looks like a bug in everything else.
|
|
7409
|
+
// prettier-ignore
|
|
7410
|
+
const vertexData = new Float32Array([
|
|
7411
|
+
// x y z nx ny nz u v
|
|
7412
|
+
-hw, -hh, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0,
|
|
7413
|
+
hw, -hh, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
|
7414
|
+
hw, hh, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0,
|
|
7415
|
+
-hw, hh, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0,
|
|
7416
|
+
])
|
|
7417
|
+
// Two triangles, counter-clockwise seen from +Z. A second pair wound the
|
|
7418
|
+
// other way is how "visible from behind" is done here, rather than a
|
|
7419
|
+
// per-material cull flag the rest of the engine has no concept of.
|
|
7420
|
+
const indices = options.doubleSided ? [0, 1, 2, 0, 2, 3, 0, 2, 1, 0, 3, 2] : [0, 1, 2, 0, 2, 3]
|
|
7421
|
+
const indexData = new Uint32Array(indices)
|
|
7422
|
+
|
|
7423
|
+
// The texture table's one entry. The path is a key, not a location — the
|
|
7424
|
+
// reader below answers it from memory, so nothing is fetched and nothing is
|
|
7425
|
+
// written to disk.
|
|
7426
|
+
//
|
|
7427
|
+
// A PLAIN RELATIVE NAME under a plain directory, because the loader treats
|
|
7428
|
+
// this exactly as it treats a PMX's: it takes the model path's directory
|
|
7429
|
+
// and JOINS the texture entry onto it. A scheme-looking path went through
|
|
7430
|
+
// that as `plane://` + `plane://name` and matched nothing, so every card
|
|
7431
|
+
// came out with the untextured fallback. `plane/<name>` joins to
|
|
7432
|
+
// `plane/<name>` and stays unique per card, which the engine-wide texture
|
|
7433
|
+
// cache needs it to be.
|
|
7434
|
+
const texturePath = `plane/${name}`
|
|
7435
|
+
const material: Material = {
|
|
7436
|
+
name,
|
|
7437
|
+
diffuse: [1, 1, 1, 1],
|
|
7438
|
+
specular: [0, 0, 0],
|
|
7439
|
+
ambient: [0, 0, 0],
|
|
7440
|
+
shininess: 0,
|
|
7441
|
+
diffuseTextureIndex: 0,
|
|
7442
|
+
normalTextureIndex: -1,
|
|
7443
|
+
sphereTextureIndex: -1,
|
|
7444
|
+
sphereMode: 0,
|
|
7445
|
+
toonTextureIndex: -1,
|
|
7446
|
+
sharedToon: false,
|
|
7447
|
+
// 0 CARRIES TWO DECISIONS, both wanted, and both silent if changed.
|
|
7448
|
+
//
|
|
7449
|
+
// No inverted-hull outline (bit 0x10): a card is not a character, and a
|
|
7450
|
+
// black rim around a light leak is the opposite of what it is for.
|
|
7451
|
+
//
|
|
7452
|
+
// AND NO SHADOW (bit 0x04, which is what castsShadow reads). A card is
|
|
7453
|
+
// usually light or artwork rather than an object, and a rectangle of hard
|
|
7454
|
+
// shadow thrown across the stage by a gradient reads as the renderer
|
|
7455
|
+
// being broken. Set geometry that SHOULD cast one is the rarer case, and
|
|
7456
|
+
// it can say so.
|
|
7457
|
+
edgeFlag: 0,
|
|
7458
|
+
edgeColor: [0, 0, 0, 1],
|
|
7459
|
+
edgeSize: 0,
|
|
7460
|
+
vertexCount: indices.length,
|
|
7461
|
+
}
|
|
7462
|
+
|
|
7463
|
+
// ONE BONE, because Model requires one — it throws on an empty skeleton,
|
|
7464
|
+
// every vertex has to be skinned to something, and a card has nothing to
|
|
7465
|
+
// articulate. It is never posed; the model transform is what moves a card.
|
|
7466
|
+
const skeleton: Skeleton = {
|
|
7467
|
+
bones: [{ name: "全ての親", parentIndex: -1, bindTranslation: [0, 0, 0], children: [] }],
|
|
7468
|
+
inverseBindMatrices: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
7469
|
+
}
|
|
7470
|
+
const vertexCount = 4
|
|
7471
|
+
const joints = new Uint16Array(vertexCount * 4)
|
|
7472
|
+
const weights = new Uint8Array(vertexCount * 4)
|
|
7473
|
+
for (let i = 0; i < vertexCount; i++) weights[i * 4] = 255
|
|
7474
|
+
|
|
7475
|
+
const model = new Model(
|
|
7476
|
+
vertexData,
|
|
7477
|
+
indexData,
|
|
7478
|
+
[{ path: name, name }],
|
|
7479
|
+
[material],
|
|
7480
|
+
skeleton,
|
|
7481
|
+
{ joints, weights },
|
|
7482
|
+
{ morphs: [] },
|
|
7483
|
+
)
|
|
7484
|
+
|
|
7485
|
+
// ITS OWN PATH, not addStage's. A plane and a stage skip the same machinery
|
|
7486
|
+
// and mean different things, and routing one through the other is how the
|
|
7487
|
+
// ground came to be suppressed by adding a picture.
|
|
7488
|
+
// The picture answers from memory, whatever it is asked for: a card has
|
|
7489
|
+
// exactly ONE texture, so there is nothing to disambiguate and no way for a
|
|
7490
|
+
// path to be wrong. Matching the string instead is what silently produced
|
|
7491
|
+
// untextured cards, because the loader composes that string itself.
|
|
7492
|
+
const reader: AssetReader = { readBinary: async () => image }
|
|
7493
|
+
const key = await this.addModel(model, texturePath, name, reader, { plane: true, dynamic: options.dynamic })
|
|
7494
|
+
|
|
7495
|
+
// UNLIT, because a card is FOOTAGE and not a surface.
|
|
7496
|
+
//
|
|
7497
|
+
// Its pixels were finished somewhere else — a gradient painted in
|
|
7498
|
+
// Photoshop, a title, a rendered element — so its brightness is the artwork
|
|
7499
|
+
// rather than a response to anything. Shading it means the sun dimming one
|
|
7500
|
+
// side of a thing that has no sides, and the world colour tinting a picture
|
|
7501
|
+
// whose colour was the point. Left ungrouped it would take the neutral
|
|
7502
|
+
// Principled base, which is exactly that mistake.
|
|
7503
|
+
//
|
|
7504
|
+
// A group, not a hard-coded pipeline: a card used as SET geometry — a photo
|
|
7505
|
+
// of a wall, a poster standing in the room — genuinely does want the light,
|
|
7506
|
+
// and this is the same control every other material is changed through, so
|
|
7507
|
+
// that case is a graph swap rather than a feature request.
|
|
7508
|
+
await this.applyStyleGroups(key, [
|
|
7509
|
+
{ id: "plane", label: "Plane", materials: [name], graph: UNLIT_GRAPH, alphaMode: "hashed" },
|
|
7510
|
+
])
|
|
7511
|
+
|
|
7512
|
+
if (options.transform) this.setModelTransform(key, options.transform)
|
|
7513
|
+
// Kept so a moving card can push frames into it. The cache is keyed by the
|
|
7514
|
+
// texture's logical path, which is derived rather than stored anywhere the
|
|
7515
|
+
// caller can see — and deriving it twice is how the two would drift.
|
|
7516
|
+
const tex = this.textureCache.get(texturePath)
|
|
7517
|
+
if (tex) this.planeTextures.set(key, tex)
|
|
7518
|
+
return key
|
|
7519
|
+
}
|
|
7520
|
+
|
|
7521
|
+
/**
|
|
7522
|
+
* Replace what a card is showing, in place.
|
|
7523
|
+
*
|
|
7524
|
+
* For a moving card: a video element, a decoded frame, a canvas — anything
|
|
7525
|
+
* copyExternalImageToTexture accepts. Nothing is reallocated and no bind group
|
|
7526
|
+
* is rebuilt, so this is a per-frame call rather than a per-clip one; the
|
|
7527
|
+
* texture is written where it stands and the material keeps pointing at it.
|
|
7528
|
+
*
|
|
7529
|
+
* The frame must be the size the card was created at. A card is a fixed
|
|
7530
|
+
* rectangle of texels and resizing one mid-clip would mean rebuilding the
|
|
7531
|
+
* material behind it — so the caller allocates the card at its video's size
|
|
7532
|
+
* and this refuses anything else rather than stretching it silently.
|
|
7533
|
+
*/
|
|
7534
|
+
setPlaneFrame(id: string, source: GPUCopyExternalImageSource, width: number, height: number): boolean {
|
|
7535
|
+
const tex = this.planeTextures.get(id)
|
|
7536
|
+
if (!tex || !this.device) return false
|
|
7537
|
+
if (tex.width !== width || tex.height !== height) return false
|
|
7538
|
+
this.device.queue.copyExternalImageToTexture({ source }, { texture: tex }, [width, height])
|
|
7539
|
+
// A moving card is allocated with one level precisely so this is never
|
|
7540
|
+
// reached: rebuilding a mip pyramid per frame is a pass per level per card.
|
|
7541
|
+
if (tex.mipLevelCount > 1) this.generateMipmaps(tex, tex.mipLevelCount)
|
|
7542
|
+
return true
|
|
7543
|
+
}
|
|
7544
|
+
|
|
7270
7545
|
/** True while a stage is in the scene. Two things turn on it: the built-in
|
|
7271
7546
|
* ground plane must not draw, and the far shadow cascade has nothing to
|
|
7272
7547
|
* cover without one (see the cascade loop). */
|
|
@@ -7289,6 +7564,9 @@ export class Engine {
|
|
|
7289
7564
|
removeModel(name: string): void {
|
|
7290
7565
|
const inst = this.modelInstances.get(name)
|
|
7291
7566
|
if (!inst) return
|
|
7567
|
+
// Before the texture cache below frees it: a stale entry here would hand a
|
|
7568
|
+
// destroyed texture to the next setPlaneFrame.
|
|
7569
|
+
this.planeTextures.delete(name)
|
|
7292
7570
|
inst.model.stop()
|
|
7293
7571
|
for (const path of inst.textureCacheKeys) {
|
|
7294
7572
|
const tex = this.textureCache.get(path)
|
|
@@ -7563,10 +7841,10 @@ export class Engine {
|
|
|
7563
7841
|
// A stage never solves IK — nothing drives its chains — and skips the pose
|
|
7564
7842
|
// pass entirely while it is idle. Morph changes still come through, since
|
|
7565
7843
|
// that is the one thing a stage's controls do move.
|
|
7566
|
-
const stageIdle = inst.isStage && inst.model.isIdle()
|
|
7844
|
+
const stageIdle = (inst.isStage || inst.isPlane) && inst.model.isIdle()
|
|
7567
7845
|
let verticesChanged = false
|
|
7568
7846
|
if (!stageIdle) {
|
|
7569
|
-
verticesChanged = inst.model.update(deltaTime, inst.isStage ? false : this.ikEnabled)
|
|
7847
|
+
verticesChanged = inst.model.update(deltaTime, inst.isStage || inst.isPlane ? false : this.ikEnabled)
|
|
7570
7848
|
inst.skinMatricesDirty = true
|
|
7571
7849
|
}
|
|
7572
7850
|
animMs += performance.now() - tAnim
|
|
@@ -7989,6 +8267,8 @@ export class Engine {
|
|
|
7989
8267
|
|
|
7990
8268
|
/** Every shadow caster in one sphere: (x, y, z, radius). radius 0 = nothing
|
|
7991
8269
|
* casts, -1 = do not use (a rigid caster has no sphere). See updateCasterSphere. */
|
|
8270
|
+
/** A card's own texture, by model key — see setPlaneFrame. */
|
|
8271
|
+
private planeTextures = new Map<string, GPUTexture>()
|
|
7992
8272
|
private casterSphere = new Float32Array(4)
|
|
7993
8273
|
|
|
7994
8274
|
/** The ground's uniform block, kept so the caster sphere can be refreshed in
|
|
@@ -8560,6 +8840,8 @@ export class Engine {
|
|
|
8560
8840
|
basePath: string,
|
|
8561
8841
|
assetReader: AssetReader,
|
|
8562
8842
|
isStage = false,
|
|
8843
|
+
isPlane = false,
|
|
8844
|
+
dynamicTexture = false,
|
|
8563
8845
|
): Promise<void> {
|
|
8564
8846
|
const vertices = model.getVertices()
|
|
8565
8847
|
const skinning = model.getSkinning()
|
|
@@ -8620,7 +8902,7 @@ export class Engine {
|
|
|
8620
8902
|
// A stage never simulates, so its bodies are never built — constructing the
|
|
8621
8903
|
// solver for the heaviest mesh in the scene and dropping it afterwards was
|
|
8622
8904
|
// both wasted work and an invariant maintained in the wrong place.
|
|
8623
|
-
const physics = !isStage && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
|
|
8905
|
+
const physics = !isStage && !isPlane && rbs.length > 0 ? new RezePhysics(rbs, model.getJoints()) : null
|
|
8624
8906
|
// Which bones the simulation will overwrite, handed to the pose pipeline so
|
|
8625
8907
|
// the append (付与) pass can consume the simulated result instead of the
|
|
8626
8908
|
// animated one. Precomputed here, once, because the answer is topology —
|
|
@@ -8700,6 +8982,8 @@ export class Engine {
|
|
|
8700
8982
|
pickPerInstanceBindGroup,
|
|
8701
8983
|
pickDrawCalls: [],
|
|
8702
8984
|
isStage,
|
|
8985
|
+
isPlane,
|
|
8986
|
+
dynamicTexture,
|
|
8703
8987
|
// Seeded true: the bind pose has to reach the GPU once before any frame.
|
|
8704
8988
|
skinMatricesDirty: true,
|
|
8705
8989
|
hiddenMaterials: new Set(),
|
|
@@ -9200,7 +9484,21 @@ export class Engine {
|
|
|
9200
9484
|
bounds[4] += grow
|
|
9201
9485
|
bounds[5] += grow
|
|
9202
9486
|
|
|
9203
|
-
|
|
9487
|
+
// A CARD IS ALWAYS OPAQUE-PHASE, whatever its alpha says.
|
|
9488
|
+
//
|
|
9489
|
+
// The scene pass runs opaque -> ground -> transparent, and the ground
|
|
9490
|
+
// writes depth at every opacity (effects locate the floor by it). Every
|
|
9491
|
+
// card qualifies as transparent — a cutout has translucent texels, and a
|
|
9492
|
+
// video card starts from a blank sheet that is nothing but — so cards
|
|
9493
|
+
// drew after the ground and an INVISIBLE floor rejected them. Turning the
|
|
9494
|
+
// ground down for the shadow catcher made pictures disappear into it.
|
|
9495
|
+
//
|
|
9496
|
+
// Not a workaround: alphaMode "hashed" is alpha-to-coverage, which is the
|
|
9497
|
+
// transparency technique built for this phase, and addPlane already sets
|
|
9498
|
+
// it. The cost is dithering on a large soft gradient, where MSAA has four
|
|
9499
|
+
// coverage levels to spend — a cutout edge, which is what a card usually
|
|
9500
|
+
// has, resolves exactly.
|
|
9501
|
+
const type: DrawCallType = inst.isPlane ? "opaque" : isTransparent ? "transparent" : "opaque"
|
|
9204
9502
|
inst.drawCalls.push({
|
|
9205
9503
|
type,
|
|
9206
9504
|
count: indexCount,
|
|
@@ -9516,7 +9814,13 @@ export class Engine {
|
|
|
9516
9814
|
}
|
|
9517
9815
|
this.textureAlphaCache.set(cacheKey, alphaPlane)
|
|
9518
9816
|
|
|
9519
|
-
|
|
9817
|
+
// NO MIPS FOR A MOVING CARD. The chain would have to be rebuilt on every
|
|
9818
|
+
// frame written into it — a full pyramid of render passes per video plane
|
|
9819
|
+
// per frame, which is most of what a moving card was costing. Level 0 is
|
|
9820
|
+
// the only level a card in frame reads anyway; the price is aliasing on one
|
|
9821
|
+
// shrunk far into the distance, which is the case a video card is least
|
|
9822
|
+
// often in.
|
|
9823
|
+
const mipLevelCount = inst.dynamicTexture ? 1 : Math.floor(Math.log2(Math.max(width, height))) + 1
|
|
9520
9824
|
const texture = this.device.createTexture({
|
|
9521
9825
|
label: `texture: ${cacheKey}`,
|
|
9522
9826
|
size: [width, height],
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Unlit — the texture, at its own brightness, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// What FOOTAGE needs. A media plane carries pixels somebody already finished
|
|
4
|
+
// somewhere else: a gradient painted in Photoshop, a title card, a rendered
|
|
5
|
+
// element. Shading it is not a stylistic choice but a mistake — the scene's sun
|
|
6
|
+
// would dim one side of a card that has no side, and the world colour would
|
|
7
|
+
// tint artwork whose colour is the point. Blender's answer is the same one:
|
|
8
|
+
// wire the image straight into an Emission shader and let it out at the value
|
|
9
|
+
// it was authored at.
|
|
10
|
+
//
|
|
11
|
+
// Emission rather than Principled with roughness 1: emission is radiance, so it
|
|
12
|
+
// leaves the light loop entirely instead of being an unusually flat surface
|
|
13
|
+
// inside it. That is also what lets a card sit in front of a lamp without
|
|
14
|
+
// picking up its highlight.
|
|
15
|
+
|
|
16
|
+
import type { ShaderGraph } from "../schema"
|
|
17
|
+
|
|
18
|
+
export const UNLIT_GRAPH: ShaderGraph = {
|
|
19
|
+
version: 1,
|
|
20
|
+
name: "Unlit",
|
|
21
|
+
tags: ["unlit", "plane"],
|
|
22
|
+
nodes: [
|
|
23
|
+
{ id: "tex", type: "texture" },
|
|
24
|
+
// The PMX material colour still multiplies in, so a plane can be tinted or
|
|
25
|
+
// faded through the same dial every other material uses rather than needing
|
|
26
|
+
// one of its own.
|
|
27
|
+
{ id: "mat", type: "material_diffuse" },
|
|
28
|
+
{ id: "base", type: "mix/multiply", inputs: { fac: 1.0 } },
|
|
29
|
+
{ id: "emit", type: "emission", inputs: { strength: 1.0 } },
|
|
30
|
+
],
|
|
31
|
+
links: [
|
|
32
|
+
{ from: { node: "tex", socket: "color" }, to: { node: "base", socket: "a" } },
|
|
33
|
+
{ from: { node: "mat", socket: "color" }, to: { node: "base", socket: "b" } },
|
|
34
|
+
{ from: { node: "base", socket: "color" }, to: { node: "emit", socket: "color" } },
|
|
35
|
+
],
|
|
36
|
+
output: { node: "emit", socket: "color" },
|
|
37
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -69,6 +69,7 @@ export { BODY_GRAPH } from "./graph/presets/body"
|
|
|
69
69
|
export { STOCKINGS_GRAPH } from "./graph/presets/stockings"
|
|
70
70
|
export { EYE_GRAPH } from "./graph/presets/eye"
|
|
71
71
|
export { FACE_GRAPH } from "./graph/presets/face"
|
|
72
|
+
export { UNLIT_GRAPH } from "./graph/presets/unlit"
|
|
72
73
|
export {
|
|
73
74
|
Model,
|
|
74
75
|
MATERIAL_MORPH_MULTIPLY,
|
|
@@ -28,8 +28,17 @@ export const LYRICS_FLOATS = LYRIC_HEADER + LYRIC_LINES_MAX * LYRIC_STRIDE
|
|
|
28
28
|
/** Bounds on the line atlas the host packs rasterised lines into. It is sized
|
|
29
29
|
* to the track that arrives rather than allocated at the maximum: a scene with
|
|
30
30
|
* no lyrics carries a 1×1 placeholder, and a song's atlas is as tall as its
|
|
31
|
-
* own lines need. 8192 is the smallest texture dimension WebGPU guarantees.
|
|
32
|
-
|
|
31
|
+
* own lines need. 8192 is the smallest texture dimension WebGPU guarantees.
|
|
32
|
+
*
|
|
33
|
+
* WIDTH IS WHAT DECIDES SHARPNESS on a long line. A lyric spans most of the
|
|
34
|
+
* frame — the shipped effect leaves 6% a side — so at 4K it is drawn across
|
|
35
|
+
* some 3400 pixels, and 2048 could only ever be stretched to cover them. That
|
|
36
|
+
* is the stair-stepped text on a 4K render, and no row height fixes it: the
|
|
37
|
+
* line is already as wide as the sheet. 1080p never showed it, because 1690
|
|
38
|
+
* pixels of line fit inside 2048. The atlas is r8unorm, one byte a texel, and
|
|
39
|
+
* only ever as wide as its own longest line — so this ceiling costs nothing
|
|
40
|
+
* until a song actually needs it. */
|
|
41
|
+
export const LYRIC_ATLAS_MAX_W = 4096
|
|
33
42
|
export const LYRIC_ATLAS_MAX_H = 8192
|
|
34
43
|
|
|
35
44
|
export type LyricLine = {
|