reze-engine 0.63.0 → 0.64.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.
Files changed (82) hide show
  1. package/dist/engine.d.ts +95 -57
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +465 -162
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +4 -1
  6. package/dist/graph/registry.d.ts +3 -0
  7. package/dist/graph/registry.d.ts.map +1 -1
  8. package/dist/graph/registry.js +133 -0
  9. package/dist/graph/schema.d.ts +12 -0
  10. package/dist/graph/schema.d.ts.map +1 -1
  11. package/dist/graph/slots.d.ts.map +1 -1
  12. package/dist/graph/slots.js +11 -20
  13. package/dist/index.d.ts +9 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/shaders/lights.d.ts +3 -2
  17. package/dist/shaders/lights.d.ts.map +1 -1
  18. package/dist/shaders/lights.js +16 -3
  19. package/dist/shaders/materials/common.d.ts.map +1 -1
  20. package/dist/shaders/materials/common.js +63 -56
  21. package/dist/shaders/materials/nodes.d.ts +1 -1
  22. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  23. package/dist/shaders/materials/nodes.js +210 -0
  24. package/dist/shaders/passes/composite.d.ts.map +1 -1
  25. package/dist/shaders/passes/composite.js +13 -1
  26. package/dist/shaders/passes/cull.d.ts +1 -1
  27. package/dist/shaders/passes/cull.d.ts.map +1 -1
  28. package/dist/shaders/passes/cull.js +7 -3
  29. package/dist/shaders/passes/ground.d.ts.map +1 -1
  30. package/dist/shaders/passes/ground.js +32 -55
  31. package/dist/shaders/passes/particles.d.ts +1 -1
  32. package/dist/shaders/passes/particles.js +1 -1
  33. package/dist/shaders/scene-light-api.d.ts +18 -10
  34. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  35. package/dist/shaders/scene-light-api.js +58 -46
  36. package/dist/shadow-cascades.d.ts +47 -20
  37. package/dist/shadow-cascades.d.ts.map +1 -1
  38. package/dist/shadow-cascades.js +36 -13
  39. package/dist/unity/character.d.ts +59 -0
  40. package/dist/unity/character.d.ts.map +1 -0
  41. package/dist/unity/character.js +120 -0
  42. package/dist/unity/globals.d.ts +93 -0
  43. package/dist/unity/globals.d.ts.map +1 -0
  44. package/dist/unity/globals.js +340 -0
  45. package/dist/unity/host.d.ts +137 -0
  46. package/dist/unity/host.d.ts.map +1 -0
  47. package/dist/unity/host.js +489 -0
  48. package/dist/unity/looks.d.ts +88 -0
  49. package/dist/unity/looks.d.ts.map +1 -0
  50. package/dist/unity/looks.js +295 -0
  51. package/dist/unity/skin.d.ts +9 -0
  52. package/dist/unity/skin.d.ts.map +1 -0
  53. package/dist/unity/skin.js +117 -0
  54. package/dist/unity/stage.d.ts +148 -0
  55. package/dist/unity/stage.d.ts.map +1 -0
  56. package/dist/unity/stage.js +375 -0
  57. package/dist/unity/state.d.ts +22 -0
  58. package/dist/unity/state.d.ts.map +1 -0
  59. package/dist/unity/state.js +170 -0
  60. package/package.json +1 -1
  61. package/src/engine.ts +503 -176
  62. package/src/graph/compile.ts +3 -1
  63. package/src/graph/registry.ts +142 -0
  64. package/src/graph/schema.ts +9 -0
  65. package/src/graph/slots.ts +11 -20
  66. package/src/index.ts +11 -0
  67. package/src/shaders/lights.ts +16 -3
  68. package/src/shaders/materials/common.ts +63 -57
  69. package/src/shaders/materials/nodes.ts +210 -0
  70. package/src/shaders/passes/composite.ts +13 -1
  71. package/src/shaders/passes/cull.ts +7 -3
  72. package/src/shaders/passes/ground.ts +32 -55
  73. package/src/shaders/passes/particles.ts +1 -1
  74. package/src/shaders/scene-light-api.ts +59 -46
  75. package/src/shadow-cascades.ts +61 -32
  76. package/src/unity/character.ts +169 -0
  77. package/src/unity/globals.ts +443 -0
  78. package/src/unity/host.ts +587 -0
  79. package/src/unity/looks.ts +372 -0
  80. package/src/unity/skin.ts +128 -0
  81. package/src/unity/stage.ts +568 -0
  82. package/src/unity/state.ts +172 -0
package/src/engine.ts CHANGED
@@ -44,7 +44,13 @@ import {
44
44
  type SimClock,
45
45
  } from "./effect-schedule"
46
46
  import { parentKeySpan, type ModelParentKey } from "./parent-keys"
47
- import { SHADOW_CASCADES, buildShadowCascades, type ShadowBounds, type ShadowView } from "./shadow-cascades"
47
+ import { NativeHost } from "./unity/host"
48
+ import { NativeLooks, type NativeLook } from "./unity/looks"
49
+ import { NativeStage, type NativeStagePackage, type NativeStageReader } from "./unity/stage"
50
+ import { gameDir, unityFrameGlobals } from "./unity/globals"
51
+ import { outlineMaxOffsetMultiplier, towardMatrix } from "./unity/character"
52
+ import type { NativeValue } from "./unity/host"
53
+ import { SHADOW_ATLAS_SIZE, SHADOW_CASCADES, buildShadowCascades, cascadeSpheres, type ShadowBounds, type ShadowView } from "./shadow-cascades"
48
54
  import { REFLECTION_DEBUG_WGSL, buildMirrorCamera, planeFromPointNormal } from "./reflection"
49
55
  import { MIRROR_MASK_DOWNSAMPLE_WGSL, MIRROR_MAT_BYTES, mirrorShaderWgsl, mirrorShadowWgsl } from "./shaders/passes/mirror"
50
56
  import { packHalf, type HdrImage } from "./hdr"
@@ -464,6 +470,9 @@ type ParentTrack = { keys: HeldParentKey[]; applied: number; seat: [number, numb
464
470
  type SunOptions = {
465
471
  /** Linear color of the sun lamp (Blender: Light > Color). */
466
472
  color?: Vec3
473
+ /** The rendering layers it keys, as bits; every layer by default. A
474
+ * directional light from setLights takes the layers the sun leaves. */
475
+ layers?: number
467
476
  /** Lamp power in Blender units (Blender: Light > Strength). */
468
477
  strength?: number
469
478
  /** Direction sunlight travels (points FROM sun TO scene, Blender: -light.rotation.Z). */
@@ -619,8 +628,11 @@ export type ViewTransformOptions = {
619
628
  * reference Wuthering Waves projects render this way.
620
629
  *
621
630
  * "filmic" is Blender 3.6's Filmic, Medium High Contrast, baked as a LUT.
631
+ *
632
+ * "aether-gazer" is that game's own final curve, (1 - e^(-2.5x))^1.4 — what
633
+ * its scenes are graded through (composite.ts, agTransform).
622
634
  */
623
- transform: "agx" | "filmic" | "standard"
635
+ transform: "agx" | "filmic" | "standard" | "aether-gazer"
624
636
  }
625
637
 
626
638
  // Matches the reference Blender project: Filmic view, Medium High Contrast look,
@@ -923,11 +935,10 @@ interface ModelInstance {
923
935
  shadowDrawCalls: DrawCall[]
924
936
  shadowBindGroups: GPUBindGroup[]
925
937
  mainPerInstanceBindGroup: GPUBindGroup
926
- /** Its own light — fill, sun and options — when setModelFill, setModelSun or
927
- * setModelFlatSky gave it one: a 48-byte ModelLight, and the CPU copy it is
928
- * written from. */
929
- lightBuffer: GPUBuffer | null
930
- modelLight: Float32Array | null
938
+ /** Its ObjectLight — rendering layers, its own ambient — and the CPU copy it
939
+ * is written from. Every model has one: its layers decide its lights. */
940
+ lightBuffer: GPUBuffer
941
+ objectLight: Float32Array<ArrayBuffer>
931
942
  pickPerInstanceBindGroup: GPUBindGroup
932
943
  pickDrawCalls: PickDrawCall[]
933
944
  /** Environment geometry added via addStage — no physics, no IK, and it
@@ -1827,8 +1838,33 @@ export type SceneLight = {
1827
1838
  aim?: XYZ
1828
1839
  angle?: number
1829
1840
  innerAngle?: number
1841
+ /**
1842
+ * "directional": light along `aim` from infinitely far, as the sun is —
1843
+ * position and radius unused; up to three, beside the sun. Otherwise a spot
1844
+ * where an aim is given and a point where none is.
1845
+ */
1846
+ kind?: "point" | "spot" | "directional"
1847
+ /**
1848
+ * The rendering layers it reaches, as bits (RENDERING_LAYER_*). Omitted
1849
+ * reaches every layer. A stage's daylight on RENDERING_LAYER_DEFAULT and the
1850
+ * cast's key on RENDERING_LAYER_CHARACTER is how a game lights the two apart.
1851
+ */
1852
+ layers?: number
1830
1853
  }
1831
1854
 
1855
+ /**
1856
+ * Rendering layers, as the game's pipeline (URP's rendering layers) uses them:
1857
+ * every drawing carries layer bits and a light reaches it only where its mask
1858
+ * shares one. A stage, prop or plane draws on DEFAULT; the cast on DEFAULT and
1859
+ * CHARACTER, the bits Aether Gazer's characters carry (0x40000001).
1860
+ */
1861
+ export const RENDERING_LAYER_DEFAULT = 1
1862
+ export const RENDERING_LAYER_CHARACTER = 1 << 30
1863
+ /** Every layer — what a light without a mask reaches. */
1864
+ const ALL_LAYERS = 0xffffffff
1865
+ /** Where the directional slots' layer bits sit in the light uniform, in words. */
1866
+ const DIR_LAYERS_AT = 108
1867
+
1832
1868
  export class Engine {
1833
1869
  private static instance: Engine | null = null
1834
1870
 
@@ -1857,7 +1893,11 @@ export class Engine {
1857
1893
  // padded to 80. sh[0].w is the IBL flag: 0 = flat world colour, 1 = the sky.
1858
1894
  // …then the scene fog at [72..87] — see setSceneFog — and the cast's
1859
1895
  // shadow on a stage at [88..107] (colour, amount; its view-projection).
1896
+ // …and the directional slots' rendering layers at [108..111], as u32 bits
1897
+ // through lightDataWords — never through a float.
1860
1898
  private lightData = new Float32Array(112)
1899
+ private lightDataWords = new Uint32Array(this.lightData.buffer)
1900
+ private sunLayers = ALL_LAYERS
1861
1901
  private castShadow: StageCastShadow | null = null
1862
1902
  private castShadowTexture!: GPUTexture
1863
1903
  private castShadowView!: GPUTextureView
@@ -2036,7 +2076,6 @@ export class Engine {
2036
2076
  private mainPerFrameBindGroupLayout!: GPUBindGroupLayout
2037
2077
  private mainPerInstanceBindGroupLayout!: GPUBindGroupLayout
2038
2078
  /** What a model with no fill of its own binds: zero light. */
2039
- private noFillBuffer!: GPUBuffer
2040
2079
  private mainPerMaterialBindGroupLayout!: GPUBindGroupLayout
2041
2080
  private outlinePerFrameBindGroupLayout!: GPUBindGroupLayout
2042
2081
  private outlinePerMaterialBindGroupLayout!: GPUBindGroupLayout
@@ -2642,6 +2681,20 @@ export class Engine {
2642
2681
  /** Scene seconds, advanced by the frame delta — NOT wall time, so an offline
2643
2682
  * export samples the same path the editor showed. */
2644
2683
  private sceneClock = 0
2684
+ /** Models dressed in the game's own materials (unity/looks.ts); built on the
2685
+ * first look installed. */
2686
+ private nativeLooks: NativeLooks | null = null
2687
+ /** The scene pass's host for the game's shaders, and the shadow atlas's. */
2688
+ private nativeHost: NativeHost | null = null
2689
+ private nativeShadowHost: NativeHost | null = null
2690
+ /** A game stage drawn by its own shaders (unity/stage.ts). */
2691
+ private nativeStage: NativeStage | null = null
2692
+ /** The frame's Unity globals, computed once for every native draw. */
2693
+ private nativeGlobals: Record<string, NativeValue> = {}
2694
+ private nativeFallback: Record<string, GPUTextureView> = {}
2695
+ /** A 1x1 depth map at the far plane: a game shader's shadow lookup into a
2696
+ * map the scene does not have reads lit. */
2697
+ private nativeNoShadowView: GPUTextureView | null = null
2645
2698
  private trailAccum = 0
2646
2699
  /** Trail samples owed this frame, computed once so every trail on every
2647
2700
  * character samples in lockstep and their paths stay comparable. */
@@ -2719,8 +2772,9 @@ export class Engine {
2719
2772
  /** The user's own "no ground" switch — see setGroundVisible. Distinct from
2720
2773
  * hasGround, which records whether a ground was ever built. */
2721
2774
  private groundHidden = false
2722
- private shadowMapTextures: GPUTexture[] = []
2723
- private shadowMapDepthViews: GPUTextureView[] = []
2775
+ /** The sun's shadow atlas: every cascade in its own tile (shadow-cascades.ts). */
2776
+ private shadowAtlasTexture!: GPUTexture
2777
+ private shadowAtlasView!: GPUTextureView
2724
2778
  private brdfLutTexture!: GPUTexture
2725
2779
  private brdfLutView!: GPUTextureView
2726
2780
  private filmicLutTexture!: GPUTexture
@@ -3098,7 +3152,7 @@ export class Engine {
3098
3152
  // rebuilt per effect, so the compiled variant IS the flag.
3099
3153
  u[11] = this.backdropEquirectView ? 2 : showingWorld ? 3 : bg ? 1 : 0
3100
3154
  // Which display transform forms the frame (see viewTransform in composite.ts).
3101
- u[25] = v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0
3155
+ u[25] = v.transform === "aether-gazer" ? 3 : v.transform === "agx" ? 2 : v.transform === "standard" ? 1 : 0
3102
3156
  u[26] = this.canvas.width
3103
3157
  u[27] = this.canvas.height
3104
3158
  // ── Grade (viewU[7..9]) ── The UI's three tonal COLORS map to ASC CDL here,
@@ -5632,8 +5686,7 @@ export class Engine {
5632
5686
  { binding: PARTICLE_LIGHT_BINDING, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
5633
5687
  { binding: PARTICLE_LIGHT_BINDING + 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" as const } },
5634
5688
  { binding: PARTICLE_LIGHT_BINDING + 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
5635
- { binding: PARTICLE_LIGHT_BINDING + 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" as const } },
5636
- { binding: PARTICLE_LIGHT_BINDING + 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
5689
+ { binding: PARTICLE_LIGHT_BINDING + 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" as const } },
5637
5690
  ]
5638
5691
  : []),
5639
5692
  // The live count's arguments, for the COUNT kernel's layout alone. The
@@ -5682,9 +5735,8 @@ export class Engine {
5682
5735
  ? [
5683
5736
  { binding: PARTICLE_LIGHT_BINDING, resource: { buffer: this.lightUniformBuffer } },
5684
5737
  { binding: PARTICLE_LIGHT_BINDING + 1, resource: { buffer: this.shadowLightVPBuffer } },
5685
- { binding: PARTICLE_LIGHT_BINDING + 2, resource: this.shadowMapDepthViews[0] },
5686
- { binding: PARTICLE_LIGHT_BINDING + 3, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
5687
- { binding: PARTICLE_LIGHT_BINDING + 4, resource: this.shadowComparisonSampler },
5738
+ { binding: PARTICLE_LIGHT_BINDING + 2, resource: this.shadowAtlasView },
5739
+ { binding: PARTICLE_LIGHT_BINDING + 3, resource: this.shadowComparisonSampler },
5688
5740
  ]
5689
5741
  : []),
5690
5742
  ...(withIndirect && indirect ? [{ binding: PARTICLE_INDIRECT_BINDING, resource: { buffer: indirect } }] : []),
@@ -7712,12 +7764,6 @@ export class Engine {
7712
7764
  [1, 1],
7713
7765
  )
7714
7766
 
7715
- this.noFillBuffer = this.device.createBuffer({
7716
- label: "model light (none)",
7717
- size: 48,
7718
- usage: GPUBufferUsage.UNIFORM,
7719
- })
7720
-
7721
7767
  // Generic shared-toon ramp: lit white down to a soft cool shadow tone with
7722
7768
  // a tight terminator around the midpoint, approximating MMD's toon ramps.
7723
7769
  const TOON_H = 64
@@ -7813,10 +7859,10 @@ export class Engine {
7813
7859
  // The positional lights. Always bound, empty or not, so every material
7814
7860
  // pipeline shares one layout whether or not the scene has any.
7815
7861
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
7816
- // The far cascade's shadow map, then the world's sky (8) and the BRDF
7817
- // LUT (9). The sky is always bound — the 1x1 fallback when the scene
7818
- // has none — so every material pipeline keeps sharing one layout.
7819
- { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
7862
+ // The world's sky (8) and the BRDF LUT (9). The sky is always bound —
7863
+ // the 1x1 fallback when the scene has none — so every material
7864
+ // pipeline keeps sharing one layout. (7 was the far cascade's map,
7865
+ // which the atlas at 3 replaces.)
7820
7866
  { binding: 8, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7821
7867
  { binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
7822
7868
  // The cast's shadow on a stage — setStageCastShadow.
@@ -8031,17 +8077,15 @@ export class Engine {
8031
8077
  magFilter: "linear",
8032
8078
  minFilter: "linear",
8033
8079
  })
8034
- // One map per cascade, each at its own resolution — the near one crisp,
8035
- // the far one wide. Same format so one pipeline records into both.
8036
- this.shadowMapTextures = SHADOW_CASCADES.map((c, i) =>
8037
- this.device.createTexture({
8038
- label: `shadow map cascade ${i}`,
8039
- size: [c.mapSize, c.mapSize],
8040
- format: Engine.SHADOW_DEPTH_FORMAT,
8041
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8042
- }),
8043
- )
8044
- this.shadowMapDepthViews = this.shadowMapTextures.map((t) => t.createView())
8080
+ // One atlas, a tile per cascade — the layout the game's pipeline uses, and
8081
+ // one binding for every reader.
8082
+ this.shadowAtlasTexture = this.device.createTexture({
8083
+ label: "sun shadow atlas",
8084
+ size: [SHADOW_ATLAS_SIZE, SHADOW_ATLAS_SIZE],
8085
+ format: Engine.SHADOW_DEPTH_FORMAT,
8086
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
8087
+ })
8088
+ this.shadowAtlasView = this.shadowAtlasTexture.createView()
8045
8089
  // The cast's shadow on a stage (setStageCastShadow): one map, only the cast
8046
8090
  // in it, from the stage's own direction. Always allocated so every material
8047
8091
  // bind group and every instance's shadow bind group stays one shape; drawn
@@ -8141,7 +8185,6 @@ export class Engine {
8141
8185
  // Same lights the materials read. A lamp that lit the cast and not the
8142
8186
  // floor under her would read as a sticker.
8143
8187
  { binding: 6, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
8144
- { binding: 7, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
8145
8188
  // The floor mirror: the mirror camera's view-projection, the reflection
8146
8189
  // resolve, an ordinary sampler beside the comparison one, and the
8147
8190
  // mirror pass's own depth for the depth-proportional blur.
@@ -10347,7 +10390,7 @@ export class Engine {
10347
10390
  if (
10348
10391
  !this.device ||
10349
10392
  !this.mainPerFrameBindGroupLayout ||
10350
- !this.shadowMapDepthViews?.length ||
10393
+ !this.shadowAtlasView ||
10351
10394
  !this.cameraUniformBuffer ||
10352
10395
  !this.mirrorCameraBuffer ||
10353
10396
  !this.lightUniformBuffer ||
@@ -10373,11 +10416,10 @@ export class Engine {
10373
10416
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
10374
10417
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
10375
10418
  { binding: 2, resource: this.materialSampler },
10376
- { binding: 3, resource: this.shadowMapDepthViews[0] },
10419
+ { binding: 3, resource: this.shadowAtlasView },
10377
10420
  { binding: 4, resource: this.shadowComparisonSampler },
10378
10421
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10379
10422
  { binding: 6, resource: { buffer: this.lightsBuffer } },
10380
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
10381
10423
  { binding: 8, resource: env },
10382
10424
  { binding: 9, resource: this.brdfLutView },
10383
10425
  { binding: 10, resource: this.castShadowView },
@@ -10390,11 +10432,10 @@ export class Engine {
10390
10432
  { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
10391
10433
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
10392
10434
  { binding: 2, resource: this.materialSampler },
10393
- { binding: 3, resource: this.shadowMapDepthViews[0] },
10435
+ { binding: 3, resource: this.shadowAtlasView },
10394
10436
  { binding: 4, resource: this.shadowComparisonSampler },
10395
10437
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
10396
10438
  { binding: 6, resource: { buffer: this.lightsBuffer } },
10397
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
10398
10439
  { binding: 8, resource: env },
10399
10440
  { binding: 9, resource: this.brdfLutView },
10400
10441
  { binding: 10, resource: this.castShadowView },
@@ -10460,6 +10501,7 @@ export class Engine {
10460
10501
  this.lightData[base + 5] = this.sun.color.y
10461
10502
  this.lightData[base + 6] = this.sun.color.z
10462
10503
  this.lightData[base + 7] = this.sun.strength
10504
+ this.lightDataWords[DIR_LAYERS_AT + index] = this.sunLayers
10463
10505
  if (index >= this.lightCount) this.lightCount = index + 1
10464
10506
  this.updateLightBuffer()
10465
10507
  }
@@ -10594,6 +10636,7 @@ export class Engine {
10594
10636
  if (options.color) this.sun.color = options.color
10595
10637
  if (options.strength !== undefined) this.sun.strength = options.strength
10596
10638
  if (options.shadow !== undefined) this.sunShadow = Math.min(Math.max(options.shadow, 0), 1)
10639
+ if (options.layers !== undefined) this.sunLayers = options.layers >>> 0
10597
10640
  if (options.direction) {
10598
10641
  this.sun.direction = options.direction
10599
10642
  this.shadowLightVPDirty = true
@@ -10728,7 +10771,23 @@ export class Engine {
10728
10771
  * literal typed into a console. Vec3 satisfies it either way. */
10729
10772
  lights: SceneLight[] | null,
10730
10773
  ): void {
10731
- const list = (lights ?? []).slice(0, MAX_LIGHTS)
10774
+ // The directional ones go to the light uniform's slots beside the sun's —
10775
+ // three of them, in order; the rest are records the grid indexes.
10776
+ const directional = (lights ?? []).filter((l) => l.kind === "directional")
10777
+ for (let k = 1; k < 4; k++) {
10778
+ const l = directional[k - 1]
10779
+ const base = 4 + k * 8
10780
+ const a = l?.aim
10781
+ const len = a ? Math.hypot(a.x, a.y, a.z) : 0
10782
+ const i = l ? Math.max(l.intensity ?? 1, 0) : 0
10783
+ this.lightData.set(
10784
+ l && len > 0 ? [a!.x / len, a!.y / len, a!.z / len, 0, l.color.x * i, l.color.y * i, l.color.z * i, 1] : [0, 0, -1, 0, 0, 0, 0, 0],
10785
+ base,
10786
+ )
10787
+ this.lightDataWords[DIR_LAYERS_AT + k] = l && len > 0 ? (l.layers ?? ALL_LAYERS) >>> 0 : 0
10788
+ }
10789
+ this.updateLightBuffer()
10790
+ const list = (lights ?? []).filter((l) => l.kind !== "directional").slice(0, MAX_LIGHTS)
10732
10791
  this.docLightCount = list.length
10733
10792
  // RECORDS only — the header belongs to allocateLightSlots, the one writer.
10734
10793
  // This used to zero-and-upload the header region too, which left two CPU
@@ -10757,7 +10816,7 @@ export class Engine {
10757
10816
  // vector cannot be normalised, and a cone around nothing would light
10758
10817
  // nothing at all — a light that vanishes because its aim was left at the
10759
10818
  // default is worse than one that shines everywhere.
10760
- const len = l.aim ? Math.hypot(l.aim.x, l.aim.y, l.aim.z) : 0
10819
+ const len = l.aim && l.kind !== "point" ? Math.hypot(l.aim.x, l.aim.y, l.aim.z) : 0
10761
10820
  this.lightsData[b + 7] = len > 0 ? 1 : 0
10762
10821
  this.lightsData[b + 8] = len > 0 ? l.aim!.x / len : 0
10763
10822
  this.lightsData[b + 9] = len > 0 ? l.aim!.y / len : 0
@@ -10769,7 +10828,8 @@ export class Engine {
10769
10828
  const inner = len > 0 ? Math.cos((Math.min(Math.max(l.innerAngle ?? (l.angle ?? 45) * 0.8, 0), 179) / 2) * (Math.PI / 180)) : -1
10770
10829
  this.lightsData[b + 11] = outer
10771
10830
  this.lightsData[b + 12] = Math.max(inner, outer)
10772
- this.lightsData[b + 13] = 0
10831
+ // The layers it does NOT reach, as bits — inverted so a zero reaches all.
10832
+ this.lightsWords[b + 13] = ~(l.layers ?? ALL_LAYERS) >>> 0
10773
10833
  this.lightsData[b + 14] = 0
10774
10834
  this.lightsData[b + 15] = 0
10775
10835
  }
@@ -11252,6 +11312,7 @@ export class Engine {
11252
11312
  * ground plane must not draw, and the far shadow cascade has nothing to
11253
11313
  * cover without one (see the cascade loop). */
11254
11314
  hasStage(): boolean {
11315
+ if (this.nativeStage) return true
11255
11316
  for (const inst of this.modelInstances.values()) if (inst.isStage) return true
11256
11317
  return false
11257
11318
  }
@@ -11281,11 +11342,6 @@ export class Engine {
11281
11342
  this.groundHidden = !on
11282
11343
  }
11283
11344
 
11284
- /** Per cascade: does its map currently hold nothing but the cleared far plane?
11285
- * Set by the cascade loop, which skips a cascade that is unwanted and already
11286
- * cleared rather than re-clearing it every frame. */
11287
- private shadowCascadeCleared: boolean[] = []
11288
-
11289
11345
  /**
11290
11346
  * Moves a model to a new key. Nothing about it is rebuilt — same GPU
11291
11347
  * buffers, same style groups, same physics — only which string the rest
@@ -11307,96 +11363,34 @@ export class Engine {
11307
11363
  }
11308
11364
 
11309
11365
  /**
11310
- * Light one model with more than the world gives it: a FILL, linear RGB times
11311
- * its surface colour, added after its material graph. Null takes it away again.
11312
- *
11313
- * After the graph, not in its ambient: an NPR ramp read an ambient fill as
11314
- * light, so sliding it moved her shadows and flipped whole regions across a
11315
- * hard step. Added after, it brightens her evenly and every shadow stays put.
11316
- *
11317
- * A game lights its stage and its characters apart. Aether Gazer's rooms are
11318
- * lit by their lamps and a near-black ambient, while its characters take a
11319
- * flat base light of their own — so a stage whose World is right for the room
11320
- * leaves a face turned from the lamps in the dark. This is that second light,
11321
- * for the models the host counts as cast.
11322
- */
11323
- setModelFill(name: string, fill: Vec3 | null): boolean {
11324
- return this.writeModelLight(name, 0, fill)
11325
- }
11326
-
11327
- /**
11328
- * A model's own sun: the colour and strength the scene's sun has FOR THIS
11329
- * MODEL, in place of the scene's. Null gives it the scene's sun back.
11330
- *
11331
- * The other half of lighting a stage and its cast apart. A game's stage is
11332
- * lit by its own daylight — Aether Gazer's kitchen at twenty times white —
11333
- * while its characters take a key light of their own that the stage never
11334
- * states. So a stage carries the sun it was lit by, and the scene's sun
11335
- * stays the cast's. Direction and shadow are still the scene's: one sun
11336
- * casts, and it casts the same way on both.
11337
- */
11338
- setModelSun(name: string, sun: Vec3 | null): boolean {
11339
- return this.writeModelLight(name, 4, sun)
11340
- }
11341
-
11342
- /**
11343
- * Light one model by the sky's AVERAGE rather than its shape: the world's
11344
- * colour and brightness from every direction alike. False gives it the
11345
- * directional sky back.
11366
+ * One model's own ambient: the irradiance arriving at it, as the 27 floats of
11367
+ * folded SH setWorldAmbient takes (ibl.ts), in place of the world's. Null
11368
+ * gives it the world's again.
11346
11369
  *
11347
- * For a cast under an anime look. An HDRI's sky is brighter and warmer one
11348
- * way than another, and taken at each normal it shades a face with soft
11349
- * realistic gradients that no toon ramp drew — PBR on an anime face. Aether
11350
- * Gazer gives its characters one flat base light instead. World strength and
11351
- * colour still reach her; only the shape is gone. A flat World is its own
11352
- * average, so without an HDRI this changes nothing.
11370
+ * The game's pipeline lights every renderer by its own probe sample, and a
11371
+ * character by an ambient of its own; a loader that carries those hands them
11372
+ * in here. Linear radiance, strength included.
11353
11373
  */
11354
- setModelFlatSky(name: string, flat: boolean): boolean {
11355
- return this.writeModelLight(name, 8, flat ? new Vec3(1, 0, 0) : null)
11356
- }
11357
-
11358
- /** One part of a model's light, at `at` (0 the fill, 4 the sun, 8 its
11359
- * options) in its ModelLight; w says whether that part is set. All unset
11360
- * releases the buffer and the model takes the shared zero stand-in again. */
11361
- private writeModelLight(name: string, at: 0 | 4 | 8, value: Vec3 | null): boolean {
11374
+ setModelAmbient(name: string, sh: ArrayLike<number> | null): boolean {
11362
11375
  const inst = this.modelInstances.get(name)
11363
11376
  if (!inst || !this.device) return false
11364
- const light = inst.modelLight ?? new Float32Array(12)
11365
- light.set(value ? [value.x, value.y, value.z, 1] : [0, 0, 0, 0], at)
11366
- const any = light[3] > 0 || light[7] > 0 || light[11] > 0
11367
- if (!any) {
11368
- if (!inst.lightBuffer) return true
11369
- const retired = inst.lightBuffer
11370
- inst.lightBuffer = null
11371
- inst.modelLight = null
11372
- inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, null)
11373
- this.bundlesDirty = true
11374
- // Retired once the GPU is done with it: an encoded frame may still name it.
11375
- void this.device.queue.onSubmittedWorkDone().then(() => retired.destroy())
11376
- return true
11377
- }
11378
- inst.modelLight = light
11379
- if (!inst.lightBuffer) {
11380
- inst.lightBuffer = this.device.createBuffer({
11381
- label: `${name}: light`,
11382
- size: 48,
11383
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
11384
- })
11385
- inst.mainPerInstanceBindGroup = this.perInstanceBindGroup(name, inst.skinMatrixBuffer, inst.lightBuffer)
11386
- this.bundlesDirty = true
11377
+ const o = inst.objectLight
11378
+ o[4] = sh && sh.length >= 27 ? 1 : 0
11379
+ for (let i = 0; i < 9; i++) {
11380
+ o.set(sh && sh.length >= 27 ? [sh[i * 3], sh[i * 3 + 1], sh[i * 3 + 2], 0] : [0, 0, 0, 0], 8 + i * 4)
11387
11381
  }
11388
- this.device.queue.writeBuffer(inst.lightBuffer, 0, light.buffer as ArrayBuffer)
11382
+ this.device.queue.writeBuffer(inst.lightBuffer, 0, o)
11389
11383
  return true
11390
11384
  }
11391
11385
 
11392
- /** The per-model group: its skinning matrices and its light, or the zero stand-in. */
11393
- private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, light: GPUBuffer | null): GPUBindGroup {
11386
+ /** The per-model group: its skinning matrices and its ObjectLight. */
11387
+ private perInstanceBindGroup(name: string, skinMatrixBuffer: GPUBuffer, light: GPUBuffer): GPUBindGroup {
11394
11388
  return this.device.createBindGroup({
11395
11389
  label: `${name}: main per-instance bind group`,
11396
11390
  layout: this.mainPerInstanceBindGroupLayout,
11397
11391
  entries: [
11398
11392
  { binding: 0, resource: { buffer: skinMatrixBuffer } },
11399
- { binding: 1, resource: { buffer: light ?? this.noFillBuffer } },
11393
+ { binding: 1, resource: { buffer: light } },
11400
11394
  ],
11401
11395
  })
11402
11396
  }
@@ -13081,8 +13075,10 @@ export class Engine {
13081
13075
  let changed = force
13082
13076
  for (let i = 0; i < this.cullDraws.length; i++) {
13083
13077
  const { inst, draw } = this.cullDraws[i]
13078
+ // bit 0: switched off; bit 1: drawn by its native look, not its graph
13084
13079
  const v =
13085
- inst.hiddenMaterials.has(draw.materialName) || inst.morphHiddenMaterials.has(draw.materialName) ? 1 : 0
13080
+ (inst.hiddenMaterials.has(draw.materialName) || inst.morphHiddenMaterials.has(draw.materialName) ? 1 : 0) |
13081
+ (this.nativeLooks?.dresses(inst.name, draw.materialName) ? 2 : 0)
13086
13082
  if (out[i] !== v) {
13087
13083
  out[i] = v
13088
13084
  changed = true
@@ -13220,6 +13216,12 @@ export class Engine {
13220
13216
  minZ = Math.min(minZ, data[o + 18] - r); maxZ = Math.max(maxZ, data[o + 18] + r)
13221
13217
  }
13222
13218
  }
13219
+ if (this.nativeStage) {
13220
+ const nb = this.nativeStage.bounds
13221
+ minX = Math.min(minX, nb.min[0]); maxX = Math.max(maxX, nb.max[0])
13222
+ minY = Math.min(minY, nb.min[1]); maxY = Math.max(maxY, nb.max[1])
13223
+ minZ = Math.min(minZ, nb.min[2]); maxZ = Math.max(maxZ, nb.max[2])
13224
+ }
13223
13225
  if (!Number.isFinite(minX)) {
13224
13226
  this.shadowSceneBounds = null
13225
13227
  return
@@ -14067,7 +14069,19 @@ export class Engine {
14067
14069
  }),
14068
14070
  )
14069
14071
 
14070
- const mainPerInstanceBindGroup = this.perInstanceBindGroup(name, skinMatrixBuffer, null)
14072
+ // Its ObjectLight: the world's ambient, and the layers its kind draws on —
14073
+ // the cast on the character layer as well as the default one. Words:
14074
+ // layers [0..3], ambient flag [4..7], SH [8..43] — see materials/common.ts.
14075
+ const objectLight = new Float32Array(44)
14076
+ new Uint32Array(objectLight.buffer)[0] =
14077
+ isStage || isPlane || isProp ? RENDERING_LAYER_DEFAULT : (RENDERING_LAYER_DEFAULT | RENDERING_LAYER_CHARACTER) >>> 0
14078
+ const lightBuffer = this.device.createBuffer({
14079
+ label: `${name}: object light`,
14080
+ size: objectLight.byteLength,
14081
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
14082
+ })
14083
+ this.device.queue.writeBuffer(lightBuffer, 0, objectLight)
14084
+ const mainPerInstanceBindGroup = this.perInstanceBindGroup(name, skinMatrixBuffer, lightBuffer)
14071
14085
 
14072
14086
  const pickPerInstanceBindGroup = this.device.createBindGroup({
14073
14087
  label: `${name}: pick per-instance bind group`,
@@ -14106,8 +14120,8 @@ export class Engine {
14106
14120
  shadowDrawCalls: [],
14107
14121
  shadowBindGroups,
14108
14122
  mainPerInstanceBindGroup,
14109
- lightBuffer: null,
14110
- modelLight: null,
14123
+ lightBuffer,
14124
+ objectLight,
14111
14125
  pickPerInstanceBindGroup,
14112
14126
  pickDrawCalls: [],
14113
14127
  isStage,
@@ -14378,7 +14392,6 @@ export class Engine {
14378
14392
  gb[3] = fadeStart
14379
14393
  gb[4] = fadeEnd
14380
14394
  gb[5] = shadowStrength
14381
- gb[6] = 1 / SHADOW_CASCADES[0].mapSize
14382
14395
  gb[7] = gridSpacing
14383
14396
  gb[8] = gridLineWidth
14384
14397
  gb[9] = gridLineOpacity
@@ -14400,19 +14413,6 @@ export class Engine {
14400
14413
  // Which variant the draw picks. Zero is the sharp shader, which is the one
14401
14414
  // that existed before softness did.
14402
14415
  this.groundSoft = gb[18] > 0
14403
- // gb[17] — does the FAR cascade hold anything?
14404
- //
14405
- // It holds something only when a stage is loaded; that is what it exists for
14406
- // and the cascade loop already skips drawing into it otherwise, leaving it
14407
- // cleared. A cleared depth map compares as "no occluder", so the ground's far
14408
- // branch is nine comparison taps whose answer is known in advance.
14409
- //
14410
- // That branch runs wherever the NEAR cascade does not reach, and the near one
14411
- // is a 64-unit box around the camera target — so on a floor receding to the
14412
- // horizon it is most of the visible pixels, on the most expensive
14413
- // full-coverage draw in the frame. Skipping it is free in the exact sense:
14414
- // the shader takes vis = 1.0, which is what the taps would have returned.
14415
- gb[17] = this.hasStage() ? 1 : 0
14416
14416
  // gb[20..23] — the caster sphere, refreshed every frame by
14417
14417
  // writeGroundCasterSphere. Zero here so a frame that renders before the
14418
14418
  // first cull (there is one) reads "nothing casts" and skips the taps, which
@@ -14477,12 +14477,11 @@ export class Engine {
14477
14477
  entries: [
14478
14478
  { binding: 0, resource: { buffer: this.cameraUniformBuffer } },
14479
14479
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
14480
- { binding: 2, resource: this.shadowMapDepthViews[0] },
14480
+ { binding: 2, resource: this.shadowAtlasView },
14481
14481
  { binding: 3, resource: this.shadowComparisonSampler },
14482
14482
  { binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
14483
14483
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
14484
14484
  { binding: 6, resource: { buffer: this.lightsBuffer } },
14485
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
14486
14485
  { binding: 8, resource: { buffer: this.mirrorVPBuffer } },
14487
14486
  // Created in handleResize, which runs during init — before any ground
14488
14487
  // can exist to bind it.
@@ -14500,12 +14499,11 @@ export class Engine {
14500
14499
  entries: [
14501
14500
  { binding: 0, resource: { buffer: this.mirrorCameraBuffer } },
14502
14501
  { binding: 1, resource: { buffer: this.lightUniformBuffer } },
14503
- { binding: 2, resource: this.shadowMapDepthViews[0] },
14502
+ { binding: 2, resource: this.shadowAtlasView },
14504
14503
  { binding: 3, resource: this.shadowComparisonSampler },
14505
14504
  { binding: 4, resource: { buffer: this.groundShadowMaterialBuffer } },
14506
14505
  { binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
14507
14506
  { binding: 6, resource: { buffer: this.lightsBuffer } },
14508
- { binding: 7, resource: this.shadowMapDepthViews[SHADOW_CASCADES.length - 1] },
14509
14507
  { binding: 8, resource: { buffer: this.mirrorVPBuffer } },
14510
14508
  // Stand-ins, not the live mirror textures: this group draws INTO the
14511
14509
  // mirror pass, where those are the attachments.
@@ -16456,52 +16454,51 @@ export class Engine {
16456
16454
  // the final frame's depth — and the ground, which draws on `hasGround` alone,
16457
16455
  // keeps PCF-sampling a character that is no longer in the scene. One clearing
16458
16456
  // pass on the transition to empty, then it stops.
16459
- if (hasModels || this.shadowMapPopulated) {
16460
- // The far cascade is the STAGE cascade, and it costs a full pass over the
16461
- // whole cast every frame to say so. Its own spec explains what it is for —
16462
- // "a set piece 100 units out still throws" — and a scene with no stage has
16463
- // no set piece: every caster sits inside the near cascade's 64-unit box,
16464
- // which follows the camera target, and the far map's only readers are
16465
- // ground pixels beyond that box, where nothing is casting.
16466
- //
16467
- // So when no stage is loaded it is drawn ONCE, cleared, and then skipped —
16468
- // the same shape as shadowMapPopulated above, and for the same reason. A
16469
- // cleared depth map reads as "no occluder", which is the correct answer
16470
- // here rather than a missing one. Load a stage and it comes straight back.
16471
- //
16472
- // 0.43 had ONE shadow map. This is half of what the second one costs.
16457
+ // The game's globals before anything native draws: the stage's casters
16458
+ // into the atlas are the first.
16459
+ if (this.nativeStage || this.nativeLooks?.size) this.prepareNativeLooks(encoder)
16460
+ if (hasModels || this.nativeStage || this.shadowMapPopulated) {
16461
+ // The outer cascades are the STAGE's, and each costs a pass over the whole
16462
+ // cast. A scene with no stage has no set piece out there: every caster
16463
+ // sits inside the inner cascade, which follows the camera target, and
16464
+ // the outer tiles' only readers are ground pixels beyond it, where
16465
+ // nothing is casting. So without a stage only the inner tile is drawn;
16466
+ // the others keep the far plane cascade 0's pass cleared the whole atlas
16467
+ // to, which compares as "no occluder" — the correct answer there.
16473
16468
  const stage = this.hasStage()
16474
16469
  for (let ci = 0; ci < SHADOW_CASCADES.length; ci++) {
16475
- const wanted = ci === 0 || stage
16476
- // Already cleared and still unwanted — nothing to do, and the map still
16477
- // holds the far plane from the pass that cleared it.
16478
- if (!wanted && this.shadowCascadeCleared[ci]) continue
16470
+ if (ci > 0 && !stage) continue
16471
+ const c = SHADOW_CASCADES[ci]
16479
16472
  const sp = encoder.beginRenderPass({
16480
- // One timestamp pair exists for "shadow"; the near cascade wears it.
16473
+ // One timestamp pair exists for "shadow"; the inner cascade wears it.
16481
16474
  timestampWrites: ci === 0 ? this.stamps("shadow") : undefined,
16482
16475
  colorAttachments: [],
16483
16476
  depthStencilAttachment: {
16484
- view: this.shadowMapDepthViews[ci],
16477
+ view: this.shadowAtlasView,
16485
16478
  depthClearValue: 1.0,
16486
- depthLoadOp: "clear",
16479
+ depthLoadOp: ci === 0 ? "clear" : "load",
16487
16480
  depthStoreOp: "store",
16488
16481
  },
16489
16482
  })
16483
+ // The tile: a bundle draws through the pass's viewport, which it
16484
+ // cannot set itself, so one recording serves whichever tile is live.
16485
+ sp.setViewport(c.origin[0], c.origin[1], c.mapSize, c.mapSize, 0, 1)
16490
16486
  // The per-model `visible` test that used to guard this is gone: it is a
16491
16487
  // per-frame boolean, and baking it into a bundle would make toggling a
16492
16488
  // model re-record. It lives in the cull compute now, which zeroes the
16493
16489
  // instance count of an invisible model's draws.
16494
- if (wanted && this.shadowBundles[ci]) sp.executeBundles([this.shadowBundles[ci]])
16490
+ if (this.shadowBundles[ci]) sp.executeBundles([this.shadowBundles[ci]])
16495
16491
  // The mirror throws shade like anything else standing on the floor.
16496
16492
  // Direct rather than in the bundle: the bundles are recorded on scene
16497
16493
  // STRUCTURE, and a mirror comes and goes with an effect's weight.
16498
- if (wanted && this.mirrorSurface && this.mirrorShadowPipeline) {
16494
+ if (this.mirrorSurface && this.mirrorShadowPipeline) {
16499
16495
  sp.setPipeline(this.mirrorShadowPipeline)
16500
16496
  sp.setBindGroup(0, this.mirrorShadowBindGroups[ci])
16501
16497
  sp.draw(6)
16502
16498
  }
16499
+ if (this.nativeStage && this.nativeShadowHost)
16500
+ this.nativeStage.drawShadow(sp, this.nativeShadowHost, this.nativeGlobals, ci, this.shadowLightVPMatrix.subarray(ci * 16, ci * 16 + 16), 8)
16503
16501
  sp.end()
16504
- this.shadowCascadeCleared[ci] = !wanted
16505
16502
  }
16506
16503
  // The cast's shadow on a stage: drawn while a stage asks for it and has a
16507
16504
  // cast to shade it with; cleared once when it stops, then left alone.
@@ -16515,7 +16512,7 @@ export class Engine {
16515
16512
  cp.end()
16516
16513
  this.castShadowCleared = !castOn
16517
16514
  }
16518
- this.shadowMapPopulated = hasModels
16515
+ this.shadowMapPopulated = hasModels || !!this.nativeStage
16519
16516
  }
16520
16517
 
16521
16518
  // Before the particles and before the field pass: both may read the grid,
@@ -16561,6 +16558,17 @@ export class Engine {
16561
16558
  // is FALSE for the cleared buffer — every such fragment silently rejected.
16562
16559
  // One redundant word against a whole class of invisible failure.
16563
16560
  pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
16561
+ // The models dressed in the game's materials, and the game's character
16562
+ // passes after them (unity/looks.ts). They set their own stencil
16563
+ // references, so the engine's is put back after.
16564
+ if (this.nativeLooks?.size) {
16565
+ this.nativeLooks.drawOpaque(pass)
16566
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
16567
+ }
16568
+ if (this.nativeStage && this.nativeHost) {
16569
+ this.nativeStage.drawOpaque(pass, this.nativeHost, this.nativeGlobals, this.nativeFrameTextures(), this.eyeArray())
16570
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
16571
+ }
16564
16572
  if (this.hasGround) this.renderGround(pass)
16565
16573
  // After the ground for the same early-z reason, and before the transparent
16566
16574
  // phase so sheer fabric blends over the glass rather than being depth
@@ -16592,6 +16600,14 @@ export class Engine {
16592
16600
  // which was never this renderer's bottleneck.
16593
16601
  const camView = this.sceneView("camera")
16594
16602
  this.forEachInstance((inst) => this.renderModelTransparentPhase(pass, inst, camView))
16603
+ if (this.nativeStage && this.nativeHost) {
16604
+ this.nativeStage.drawTransparent(pass, this.nativeHost, this.nativeGlobals, this.nativeFrameTextures(), this.eyeArray())
16605
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
16606
+ }
16607
+ if (this.nativeLooks?.size) {
16608
+ this.nativeLooks.drawTransparent(pass)
16609
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
16610
+ }
16595
16611
  // Last in the pass: depth-tested against everything drawn above, so a
16596
16612
  // particle behind the character is simply hidden, and still inside the HDR
16597
16613
  // target so an `#bloom` effect reaches the pyramid below.
@@ -17126,11 +17142,44 @@ export class Engine {
17126
17142
 
17127
17143
  // Compile a group's graph → WGSL → pipeline(s), install keyed by group id. Reuses the
17128
17144
  // install (pipeline + uniform buffer) when the graph/integration is byte-unchanged.
17145
+ /** A graph's own images (ShaderGraph.images), fetched and decoded once per URL. */
17146
+ private graphImageCache = new Map<string, Promise<ImageBitmap | null>>()
17147
+
17148
+ private graphImage(url: string): Promise<ImageBitmap | null> {
17149
+ let p = this.graphImageCache.get(url)
17150
+ if (!p) {
17151
+ p = fetch(url)
17152
+ .then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`${r.status} ${url}`))))
17153
+ .then((b) => createImageBitmap(b, { colorSpaceConversion: "none", premultiplyAlpha: "none" }))
17154
+ .catch((e) => {
17155
+ console.warn(`[style] graph image: ${(e as Error).message}`)
17156
+ return null
17157
+ })
17158
+ this.graphImageCache.set(url, p)
17159
+ }
17160
+ return p
17161
+ }
17162
+
17163
+ /** The group with its graph's images, where the group brings none of its own. */
17164
+ private async withGraphImages(group: StyleGroup): Promise<StyleGroup> {
17165
+ const wanted = group.graph.images
17166
+ if (!wanted?.length || group.images?.length) return group
17167
+ const images = await Promise.all(
17168
+ wanted.slice(0, 4).map(async (w) => {
17169
+ if (!w) return null
17170
+ const source = await this.graphImage(w.url)
17171
+ return source ? { source, srgb: w.srgb ?? false } : null
17172
+ }),
17173
+ )
17174
+ return { ...group, images }
17175
+ }
17176
+
17129
17177
  private async compileAndInstallGroup(
17130
17178
  inst: ModelInstance,
17131
- group: StyleGroup,
17179
+ groupIn: StyleGroup,
17132
17180
  opts?: CompileOptions,
17133
17181
  ): Promise<ApplyStyleGroupResult> {
17182
+ const group = await this.withGraphImages(groupIn)
17134
17183
  const renderClass = group.renderClass ?? "auto"
17135
17184
  const alphaMode = group.alphaMode ?? "opaque"
17136
17185
  // THE MAPS ARE PART OF THE SIGNATURE, because a matching one skips the
@@ -18284,6 +18333,257 @@ export class Engine {
18284
18333
  cd[anchorBase + 11] = count
18285
18334
  }
18286
18335
 
18336
+ /**
18337
+ * Dress a model in the game's own materials — a look exported with ag-rip
18338
+ * (translated shaders, their images, per-material passes and values, and a
18339
+ * character rig) — or take it off with null. The materials it names are
18340
+ * drawn by the game's shaders from then on; the rest keep their graphs, and
18341
+ * every one still casts its shadow through the engine's pass.
18342
+ */
18343
+ setModelNativeLook(name: string, look: NativeLook | null): boolean {
18344
+ const inst = this.modelInstances.get(name)
18345
+ if (!inst || !this.device) return false
18346
+ if (!look) {
18347
+ this.nativeLooks?.remove(name)
18348
+ this.writeCullHidden(true)
18349
+ return true
18350
+ }
18351
+ if (!this.nativeLooks) this.nativeLooks = new NativeLooks(this.device, this.ensureNativeHosts())
18352
+ const skeleton = inst.model.getSkeleton()
18353
+ const head = skeleton.bones.findIndex((b) => b.name === "頭")
18354
+ // The head's rest position: a rigid inverse bind [R t] is the bind pose
18355
+ // inverted, so the bind position is −Rᵀt.
18356
+ let headRest: [number, number, number] = [0, 0, 0]
18357
+ if (head >= 0) {
18358
+ const m = skeleton.inverseBindMatrices.subarray(head * 16, head * 16 + 16)
18359
+ headRest = [
18360
+ -(m[0] * m[12] + m[1] * m[13] + m[2] * m[14]),
18361
+ -(m[4] * m[12] + m[5] * m[13] + m[6] * m[14]),
18362
+ -(m[8] * m[12] + m[9] * m[13] + m[10] * m[14]),
18363
+ ]
18364
+ }
18365
+ const draws = inst.drawCalls
18366
+ .filter((dc) => dc.baseBindGroupEntries)
18367
+ .map((dc) => ({
18368
+ materialName: dc.materialName,
18369
+ firstIndex: dc.firstIndex,
18370
+ count: dc.count,
18371
+ diffuse: dc.baseBindGroupEntries!.find((e) => e.binding === 0)!.resource as GPUTextureView,
18372
+ }))
18373
+ this.nativeLooks.install(
18374
+ {
18375
+ name,
18376
+ vertices: inst.model.getVertices(),
18377
+ indices: inst.model.getIndices(),
18378
+ vertexBuffer: inst.vertexBuffer,
18379
+ jointsBuffer: inst.jointsBuffer,
18380
+ weightsBuffer: inst.weightsBuffer,
18381
+ skinMatrixBuffer: inst.skinMatrixBuffer,
18382
+ indexBuffer: inst.indexBuffer,
18383
+ draws,
18384
+ hidden: (m) => inst.hiddenMaterials.has(m) || inst.morphHiddenMaterials.has(m),
18385
+ headSkin: () => (head >= 0 ? inst.model.getSkinMatrices().subarray(head * 16, head * 16 + 16) : null),
18386
+ headRest,
18387
+ layers: new Uint32Array(inst.objectLight.buffer)[0],
18388
+ },
18389
+ look,
18390
+ )
18391
+ this.writeCullHidden(true)
18392
+ return true
18393
+ }
18394
+
18395
+ /** The hosts for the game's shaders: the scene pass's and the shadow atlas's,
18396
+ * with the stand-ins an empty slot reads. Built on first use. */
18397
+ private ensureNativeHosts(): NativeHost {
18398
+ if (this.nativeHost) return this.nativeHost
18399
+ const d = this.device
18400
+ const noShadow = d.createTexture({
18401
+ label: "native: no shadow",
18402
+ size: [1, 1],
18403
+ format: "depth32float",
18404
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
18405
+ })
18406
+ const enc = d.createCommandEncoder()
18407
+ enc
18408
+ .beginRenderPass({
18409
+ colorAttachments: [],
18410
+ depthStencilAttachment: { view: noShadow.createView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" },
18411
+ })
18412
+ .end()
18413
+ d.queue.submit([enc.finish()])
18414
+ this.nativeNoShadowView = noShadow.createView()
18415
+ const solid = (label: string, rgba: number[], cube = false) => {
18416
+ const t = d.createTexture({
18417
+ label,
18418
+ size: [1, 1, cube ? 6 : 1],
18419
+ format: "rgba8unorm",
18420
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
18421
+ })
18422
+ for (let l = 0; l < (cube ? 6 : 1); l++) d.queue.writeTexture({ texture: t, origin: [0, 0, l] }, new Uint8Array(rgba), { bytesPerRow: 4 }, [1, 1])
18423
+ return t.createView(cube ? { dimension: "cube" } : undefined)
18424
+ }
18425
+ const white = solid("native: white", [255, 255, 255, 255])
18426
+ const black = solid("native: black", [0, 0, 0, 255])
18427
+ // Unity's stand-ins for an empty slot, by the name a shader declares.
18428
+ this.nativeFallback = {
18429
+ white,
18430
+ black,
18431
+ "": white,
18432
+ grey: solid("native: grey", [128, 128, 128, 255]),
18433
+ gray: solid("native: gray", [128, 128, 128, 255]),
18434
+ bump: solid("native: bump", [128, 128, 255, 255]),
18435
+ red: solid("native: red", [255, 0, 0, 255]),
18436
+ }
18437
+ const fallback = {
18438
+ white,
18439
+ black,
18440
+ blackCube: solid("native: black cube", [0, 0, 0, 255], true),
18441
+ depth: this.nativeNoShadowView,
18442
+ comparison: this.shadowComparisonSampler,
18443
+ }
18444
+ this.nativeHost = new NativeHost(
18445
+ d,
18446
+ {
18447
+ colorFormats: sceneColorFormats(this.sceneFormats),
18448
+ depthFormat: this.depthFormat,
18449
+ sampleCount: Engine.MULTISAMPLE_COUNT,
18450
+ reversedZ: this.reversedZ,
18451
+ },
18452
+ fallback,
18453
+ )
18454
+ this.nativeShadowHost = new NativeHost(d, { colorFormats: [], depthFormat: Engine.SHADOW_DEPTH_FORMAT, sampleCount: 1, reversedZ: false }, fallback)
18455
+ return this.nativeHost
18456
+ }
18457
+
18458
+ /**
18459
+ * Load a game stage as the game draws it - a package ag-rip's
18460
+ * stage_native.py exported (stage.json and the files beside it, read through
18461
+ * `read` by their paths in it) - or remove the one loaded with null. Its
18462
+ * scene settings (fog, tint, ambient, environment) become the game shaders'
18463
+ * for the whole scene; its lights are the caller's to set, as lights.
18464
+ */
18465
+ async setNativeStage(pkg: NativeStagePackage | null, read?: NativeStageReader, onProgress?: (done: number, total: number) => void): Promise<void> {
18466
+ const old = this.nativeStage
18467
+ this.nativeStage = null
18468
+ if (old) old.destroy([this.nativeHost!, this.nativeShadowHost!])
18469
+ if (!pkg || !read || !this.device) return
18470
+ const scene = this.ensureNativeHosts()
18471
+ this.nativeStage = await NativeStage.load(
18472
+ this.device,
18473
+ pkg,
18474
+ read,
18475
+ { scene, shadow: this.nativeShadowHost! },
18476
+ { mipmaps: (t, levels) => this.generateMipmaps(t, levels), fallback: this.nativeFallback },
18477
+ onProgress,
18478
+ )
18479
+
18480
+ }
18481
+
18482
+ /** What went wrong in the game's shaders, and the names nothing supplied. */
18483
+ nativeReport(): { errors: string[]; missing: Record<string, number> } {
18484
+ const errors = [...(this.nativeHost?.errors ?? []), ...(this.nativeShadowHost?.errors ?? [])]
18485
+ const missing: Record<string, number> = {}
18486
+ for (const h of [this.nativeHost, this.nativeShadowHost]) for (const [k, v] of h?.missing ?? []) missing[k] = (missing[k] ?? 0) + v
18487
+ return { errors, missing }
18488
+ }
18489
+
18490
+ private nativeFrameTextures(): Record<string, GPUTextureView | undefined> {
18491
+ return { _MainLightShadowmapTexture: this.shadowAtlasView, sim_CharacterShadowmap: this.nativeNoShadowView ?? undefined }
18492
+ }
18493
+
18494
+ private eyeArray(): [number, number, number] {
18495
+ const e = this.camera.getEyePosition()
18496
+ return [e.x, e.y, e.z]
18497
+ }
18498
+
18499
+ /** Before the scene pass: the dressed models' skinning and the game's globals. */
18500
+ private prepareNativeLooks(encoder: GPUCommandEncoder): void {
18501
+ const looks = this.nativeLooks
18502
+ const view = this.camera.getViewMatrix().values
18503
+ const proj = this.camera.getProjectionMatrix().values
18504
+ const eye = this.camera.getEyePosition()
18505
+ const sun = this.sun
18506
+ const dir = sun.direction.normalize()
18507
+ const k = sun.strength
18508
+ // The document's lamps as their records store them.
18509
+ const lights = []
18510
+ for (let i = 0; i < this.docLightCount; i++) {
18511
+ const b = LIGHT_HEADER + i * LIGHT_STRIDE
18512
+ const d = this.lightsData
18513
+ lights.push({
18514
+ position: [d[b], d[b + 1], d[b + 2]] as [number, number, number],
18515
+ radius: d[b + 3],
18516
+ color: [d[b + 4], d[b + 5], d[b + 6]] as [number, number, number],
18517
+ aim: [d[b + 8], d[b + 9], d[b + 10]] as [number, number, number],
18518
+ cosOuter: d[b + 11],
18519
+ cosInner: d[b + 12],
18520
+ layers: ~this.lightsWords[b + 13] >>> 0,
18521
+ })
18522
+ }
18523
+ // A game stage brings its own lights: the game's shaders get exactly those
18524
+ // (its sun, its lamps, all layers), whatever the scene's lamps are.
18525
+ const stage = this.nativeStage
18526
+ let sunDirection: [number, number, number] = [dir.x, dir.y, dir.z]
18527
+ let sunColor: [number, number, number] = [sun.color.x * k, sun.color.y * k, sun.color.z * k]
18528
+ let sunShadow = this.sunShadow
18529
+ if (stage) {
18530
+ // Its lamps are the stage's own; its key is the scene's sun, which the
18531
+ // host sets from the stage (and a person may then move).
18532
+ const L = stage.pkg.lights
18533
+ const sc = stage.pkg.scale
18534
+ lights.length = 0
18535
+ for (const l of L.additional) {
18536
+ lights.push({
18537
+ position: [-l.position[0] * sc, l.position[1] * sc, -l.position[2] * sc] as [number, number, number],
18538
+ radius: l.range * sc,
18539
+ color: [l.color[0] * sc * sc, l.color[1] * sc * sc, l.color[2] * sc * sc] as [number, number, number],
18540
+ aim: (l.aim ? gameDir(l.aim) : [0, 0, 0]) as [number, number, number],
18541
+ cosOuter: l.cosOuter ?? -1,
18542
+ cosInner: l.cosInner ?? -1,
18543
+ layers: 0xffffffff,
18544
+ unity: l.unity,
18545
+ })
18546
+ }
18547
+ }
18548
+ const s = this.world.strength
18549
+ const sh = this.worldAmbientSH ?? this.worldSH ?? this.worldGradientSH
18550
+ const g = unityFrameGlobals({
18551
+ scale: 8,
18552
+ view,
18553
+ proj,
18554
+ eye: [eye.x, eye.y, eye.z],
18555
+ near: this.camera.near,
18556
+ far: this.camera.far,
18557
+ width: this.canvas.width,
18558
+ height: this.canvas.height,
18559
+ time: this.sceneClock,
18560
+ dt: 1 / 60,
18561
+ sunDirection,
18562
+ sunColor,
18563
+ sunShadow,
18564
+ lights,
18565
+ ambientSH: sh ? Array.from(sh, (v) => v * s) : null,
18566
+ ambientFlat: [this.world.color.x * s, this.world.color.y * s, this.world.color.z * s],
18567
+ shadow: {
18568
+ viewProj: SHADOW_CASCADES.map((_, i) => this.shadowLightVPMatrix.subarray(i * 16, i * 16 + 16)),
18569
+ tiles: SHADOW_CASCADES.map((c) => [c.origin[0], c.origin[1]] as [number, number]),
18570
+ tileSize: SHADOW_CASCADES[0].mapSize,
18571
+ atlasSize: SHADOW_ATLAS_SIZE,
18572
+ spheres: cascadeSpheres(this.shadowView, this.shadowSceneBounds).map((p) => ({
18573
+ center: [p.center.x, p.center.y, p.center.z] as [number, number, number],
18574
+ radius: p.radius,
18575
+ })),
18576
+ },
18577
+ settings: this.nativeStage?.pkg.settings ?? null,
18578
+ })
18579
+ g.sim_TowardMatrix = towardMatrix(gameDir([view[2], view[6], view[10]]))
18580
+ g._OutlineMaxOffsetMultiplier = outlineMaxOffsetMultiplier(this.canvas.width)
18581
+ this.nativeGlobals = stableGlobals(this.nativeGlobals, g)
18582
+ this.nativeHost?.beginFrame()
18583
+ this.nativeShadowHost?.beginFrame()
18584
+ looks?.prepare(encoder, this.nativeGlobals, 8, this.nativeFrameTextures())
18585
+ }
18586
+
18287
18587
  private updateSkinMatrices() {
18288
18588
  this.forEachInstance((inst) => {
18289
18589
  // Only a pose pass can change these, and an idle stage did not run one —
@@ -18360,3 +18660,30 @@ export class Engine {
18360
18660
  this.stats.cpuRenderMs = Math.round(this.cpuRenderMs * 100) / 100
18361
18661
  }
18362
18662
  }
18663
+
18664
+ /**
18665
+ * This frame's Unity globals, keeping last frame's object for every value that
18666
+ * did not change: the native host refills a uniform member only when its value
18667
+ * object is new, so a light table or an SH that holds still costs nothing.
18668
+ */
18669
+ function stableGlobals(prev: Record<string, NativeValue>, next: Record<string, NativeValue>): Record<string, NativeValue> {
18670
+ for (const k in next) {
18671
+ const a = prev[k]
18672
+ if (a !== undefined && sameValue(a, next[k])) next[k] = a
18673
+ }
18674
+ return next
18675
+ }
18676
+
18677
+ function sameValue(a: NativeValue, b: NativeValue): boolean {
18678
+ if (typeof a === "number" || typeof b === "number") return a === b
18679
+ if (a.length !== b.length) return false
18680
+ if (a instanceof Uint32Array !== b instanceof Uint32Array) return false
18681
+ for (let i = 0; i < a.length; i++) {
18682
+ const x = a[i] as number | ArrayLike<number>
18683
+ const y = b[i] as number | ArrayLike<number>
18684
+ if (typeof x === "number" || typeof y === "number") {
18685
+ if (x !== y) return false
18686
+ } else if (!sameValue(x, y)) return false
18687
+ }
18688
+ return true
18689
+ }