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/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
- fieldLayer: /^\s*\/\/\s*@fullres\s*$/m.test(wgsl) ? 0 : 1,
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
- if (!inst.model.visible || inst.isStage) continue
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(key, model, basePath, reader, options?.stage ?? false)
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
- const type: DrawCallType = isTransparent ? "transparent" : "opaque"
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
- const mipLevelCount = Math.floor(Math.log2(Math.max(width, height))) + 1
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
- export const LYRIC_ATLAS_MAX_W = 2048
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 = {