@hraness/slopcamera 3.2.8 → 3.3.1

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 (150) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-cgpkyjqa.js +3 -0
  59. package/dist/index-f0ptv8dm.js +7 -0
  60. package/dist/index-grk47tgs.js +3 -0
  61. package/dist/index-qry58nj2.js +10 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/package.json +7 -5
  69. package/skills/slopcamera/SKILL.md +6 -1
  70. package/skills/slopcamera/references/directed-scenes.md +22 -2
  71. package/skills/slopcamera/references/gateway-media.md +15 -1
  72. package/skills/slopcamera/references/image-galleries.md +166 -0
  73. package/skills/slopcamera/references/install.md +1 -1
  74. package/skills/slopcamera/references/parametric-design.md +14 -0
  75. package/skills/slopcamera/references/scene-building.md +32 -0
  76. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  77. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  78. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  79. package/src/capability-manifest.ts +361 -0
  80. package/src/cli.ts +222 -4
  81. package/src/code/canonical-json.ts +9 -0
  82. package/src/generate.ts +2 -2
  83. package/src/icon.ts +1111 -0
  84. package/src/image-gallery.ts +1049 -0
  85. package/src/index.ts +11 -0
  86. package/src/mcp/boundary.ts +11 -11
  87. package/src/mcp/index.ts +3 -0
  88. package/src/mcp/server.ts +2 -1
  89. package/src/mcp/tools.ts +1642 -130
  90. package/src/mcp/types.ts +19 -0
  91. package/src/operations.ts +412 -0
  92. package/src/portable-capability-manifest.ts +67 -0
  93. package/src/scene-gallery.ts +184 -0
  94. package/src/spatial-scene/asset-admission.ts +183 -0
  95. package/src/spatial-scene/audit-rendered.ts +692 -0
  96. package/src/spatial-scene/audit.ts +617 -0
  97. package/src/spatial-scene/behavior-audit.ts +252 -0
  98. package/src/spatial-scene/behavior-bake.ts +233 -0
  99. package/src/spatial-scene/behavior-fns.ts +585 -0
  100. package/src/spatial-scene/behavior-gallery.ts +109 -0
  101. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  102. package/src/spatial-scene/behavior-trace.ts +325 -0
  103. package/src/spatial-scene/behavior.ts +618 -0
  104. package/src/spatial-scene/build.ts +632 -0
  105. package/src/spatial-scene/camera-rig.ts +125 -0
  106. package/src/spatial-scene/camera-track.ts +4 -2
  107. package/src/spatial-scene/character.ts +124 -0
  108. package/src/spatial-scene/contracts.ts +75 -7
  109. package/src/spatial-scene/design-templates.ts +351 -0
  110. package/src/spatial-scene/design.ts +327 -0
  111. package/src/spatial-scene/direction-compile.ts +584 -0
  112. package/src/spatial-scene/direction.ts +122 -0
  113. package/src/spatial-scene/effects.ts +149 -0
  114. package/src/spatial-scene/evaluate.ts +109 -29
  115. package/src/spatial-scene/gallery.ts +277 -0
  116. package/src/spatial-scene/generate.ts +239 -0
  117. package/src/spatial-scene/geometry-native.ts +165 -0
  118. package/src/spatial-scene/geometry.ts +1216 -0
  119. package/src/spatial-scene/gltf.ts +600 -77
  120. package/src/spatial-scene/identity.ts +55 -2
  121. package/src/spatial-scene/index.ts +36 -0
  122. package/src/spatial-scene/inspect.ts +35 -6
  123. package/src/spatial-scene/material-lighting.ts +609 -0
  124. package/src/spatial-scene/math.ts +21 -4
  125. package/src/spatial-scene/motion-evidence.ts +88 -0
  126. package/src/spatial-scene/parametric.ts +765 -0
  127. package/src/spatial-scene/particle-preparation.ts +183 -0
  128. package/src/spatial-scene/particle.ts +195 -0
  129. package/src/spatial-scene/patch.ts +109 -15
  130. package/src/spatial-scene/performance.ts +1613 -0
  131. package/src/spatial-scene/probe.ts +137 -0
  132. package/src/spatial-scene/recipe-pack.ts +86 -0
  133. package/src/spatial-scene/render-effects.ts +240 -0
  134. package/src/spatial-scene/review.ts +639 -0
  135. package/src/spatial-scene/simulation-bake.ts +206 -0
  136. package/src/spatial-scene/simulation.ts +187 -0
  137. package/src/spatial-scene/solve.ts +366 -0
  138. package/src/spatial-scene/temporal-audit.ts +354 -0
  139. package/src/version.ts +1 -1
  140. package/dist/index-42zsesc1.js +0 -2257
  141. package/dist/index-7131pg1c.js +0 -1820
  142. package/dist/index-7308egqr.js +0 -48
  143. package/dist/index-8txs6fkn.js +0 -712
  144. package/dist/index-fava6pge.js +0 -192
  145. package/dist/index-h1k0fnjq.js +0 -2363
  146. package/dist/index-r7gdhmsp.js +0 -424
  147. package/dist/index-sh6xbav6.js +0 -1219
  148. package/dist/index-z1w83f81.js +0 -4
  149. package/dist/index-zfnddgay.js +0 -2029
  150. package/dist/skill-install-dh0ntqba.js +0 -10
@@ -15,9 +15,14 @@ import {
15
15
  type SpatialCamera,
16
16
  type SpatialEntity,
17
17
  type SpatialProjection,
18
+ type SpatialSpotLight,
18
19
  } from "../../../src/spatial-scene/contracts";
19
20
  import { cameraMathView, composeTransform, invertTransform, multiplyTransforms, type Mat4 } from "../../../src/spatial-scene/math";
20
21
  import { spatialAssetClosureDigests } from "../../../src/spatial-scene/identity";
22
+ import { pbrMaterialMapAssetIds } from "../../../src/spatial-scene/material-lighting";
23
+ import { SpatialRenderEffectsBindingSchema } from "../../../src/spatial-scene/render-effects";
24
+ import { SpatialAuditBoundsSchema } from "../../../src/spatial-scene/audit";
25
+ import { SPATIAL_SPLAT_PROXY_REPRESENTATION } from "../../../src/spatial-scene/audit-rendered";
21
26
  import { canonicalJson, canonicalJsonSha256 } from "../core/canonical-json";
22
27
  import {
23
28
  HtmlOverlayAuthoringInputSchema,
@@ -30,6 +35,75 @@ import { HtmlOverlayExecutionProfileSchema } from "./execution-profile";
30
35
  import { SpatialSpzFactsSchema, SPATIAL_SPLAT_LIMITS, spatialSpzAllocationBounds } from "../contracts/spatial-world";
31
36
  import { addSpatialSplatRuntime } from "./spatial-splat-runtime";
32
37
 
38
+ /** Fixed renderer policy; source is host-owned and runs only against admitted scene meshes. */
39
+ export const SPATIAL_SHADOW_POLICY = Object.freeze({
40
+ profile: "slopcamera.caster-fit-shadows-v1",
41
+ maximumMapSize: 2048,
42
+ maximumAtlasTexels: 16_777_216,
43
+ pointMapFaces: 6,
44
+ depthBias: -0.00005,
45
+ maximumNormalBiasMeters: 0.05,
46
+ normalBiasTexels: 0.35,
47
+ });
48
+
49
+ /** Kept as one injected source seam so CPU tests execute the exact browser algorithm. */
50
+ export function spatialShadowRuntimeSource(): string {
51
+ return `const spatialShadowPolicy=${JSON.stringify(SPATIAL_SHADOW_POLICY)};
52
+ const configureSpatialShadows=(world,maximumTextureSize,track)=>{
53
+ world.updateMatrixWorld(true);
54
+ const lights=[],casters=new THREE.Box3();
55
+ for(const object of world.children){
56
+ if(object.isLight&&object.castShadow&&object.shadow)lights.push(object);
57
+ if(object.isMesh&&object.castShadow){
58
+ if(!object.geometry.boundingBox)object.geometry.computeBoundingBox();
59
+ if(object.geometry.boundingBox)casters.union(object.geometry.boundingBox.clone().applyMatrix4(object.matrixWorld));
60
+ }
61
+ }
62
+ if(lights.length===0)return;
63
+ const atlasFaces=lights.reduce((sum,light)=>sum+(light.isPointLight?spatialShadowPolicy.pointMapFaces:1),0);
64
+ const fairSize=2**Math.floor(Math.log2(Math.sqrt(spatialShadowPolicy.maximumAtlasTexels/atlasFaces)));
65
+ const center=new THREE.Vector3(),size=new THREE.Vector3();
66
+ const hasCasters=!casters.isEmpty();
67
+ if(hasCasters){casters.getCenter(center);casters.getSize(size);}
68
+ const diameter=hasCasters?Math.max(size.length(),0.001):1;
69
+ for(const light of lights){
70
+ const shadow=light.shadow;
71
+ const hardwareSize=2**Math.floor(Math.log2(maximumTextureSize));
72
+ const mapSize=Math.max(1,Math.min(spatialShadowPolicy.maximumMapSize,fairSize,hardwareSize));
73
+ shadow.mapSize.set(mapSize,mapSize);
74
+ shadow.bias=spatialShadowPolicy.depthBias;
75
+ let texelSpan=diameter/mapSize;
76
+ if(light.isDirectionalLight&&hasCasters){
77
+ const lightPosition=new THREE.Vector3().setFromMatrixPosition(light.matrixWorld);
78
+ const targetPosition=new THREE.Vector3().setFromMatrixPosition(light.target.matrixWorld);
79
+ const direction=targetPosition.sub(lightPosition).normalize();
80
+ // Directional translation has no photometric effect; move the shadow camera
81
+ // in front of all casters so translated/tall models never fall behind near.
82
+ if(direction.lengthSq()>0){
83
+ light.matrix.setPosition(center.clone().addScaledVector(direction,-diameter));
84
+ light.matrix.decompose(light.position,light.quaternion,light.scale);
85
+ light.target.position.copy(center);light.target.updateMatrixWorld(true);light.updateMatrixWorld(true);
86
+ }
87
+ shadow.updateMatrices(light);
88
+ const inLight=casters.clone().applyMatrix4(shadow.camera.matrixWorldInverse);
89
+ const extent=new THREE.Vector3();inLight.getSize(extent);
90
+ const margin=Math.max(0.001,Math.max(extent.x,extent.y)*0.05);
91
+ const camera=shadow.camera;
92
+ camera.left=inLight.min.x-margin;camera.right=inLight.max.x+margin;
93
+ camera.bottom=inLight.min.y-margin;camera.top=inLight.max.y+margin;
94
+ camera.near=Math.max(0.0001,-inLight.max.z-margin);
95
+ // Receiver-only grounds do not dilute XY resolution. Extra depth retains
96
+ // contact shadows beyond caster bounds without fitting a huge ground plane.
97
+ camera.far=Math.max(camera.near+0.001,-inLight.min.z+diameter*2);
98
+ camera.updateProjectionMatrix();shadow.updateMatrices(light);
99
+ texelSpan=Math.max(camera.right-camera.left,camera.top-camera.bottom)/mapSize;
100
+ }
101
+ shadow.normalBias=Math.min(spatialShadowPolicy.maximumNormalBiasMeters,Math.max(0.000001,texelSpan*spatialShadowPolicy.normalBiasTexels));
102
+ track(shadow);
103
+ }
104
+ };`;
105
+ }
106
+
33
107
  export const SPATIAL_OVERLAY_LIMITS = Object.freeze({
34
108
  frames: 32,
35
109
  requestBytes: 16 * 1024 * 1024,
@@ -48,6 +122,7 @@ const textureShape = {
48
122
  width: z.number().int().min(1).max(8_192),
49
123
  height: z.number().int().min(1).max(8_192),
50
124
  alpha: alphaSchema,
125
+ uvTransform: z.strictObject({ offset: z.tuple([z.number().finite(), z.number().finite()]), rotation: z.number().finite().min(-Math.PI).max(Math.PI), scale: z.tuple([z.number().finite(), z.number().finite()]) }).optional(),
51
126
  };
52
127
  const samplerSchema = z.strictObject({
53
128
  wrapS: z.union([z.literal(33071), z.literal(33648), z.literal(10497)]),
@@ -72,14 +147,28 @@ const primitiveSchema = z.strictObject({
72
147
  /** Host-resolved final material; omitted means the entity's material. */
73
148
  material: SpatialMaterialSchema.optional(),
74
149
  texture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
150
+ normalTexture: z.strictObject({ ...textureShape, scale: z.number().finite().optional(), flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
151
+ metallicRoughnessTexture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
152
+ occlusionTexture: z.strictObject({ ...textureShape, strength: z.number().finite().min(0).max(1).optional(), flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
153
+ emissiveTexture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
154
+ clearcoatTexture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
155
+ clearcoatRoughnessTexture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
156
+ clearcoatNormalTexture: z.strictObject({ ...textureShape, scale: z.number().finite().optional(), flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
157
+ transmissionTexture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
158
+ sheenColorTexture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
159
+ sheenRoughnessTexture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
160
+ anisotropyTexture: z.strictObject({ ...textureShape, flipY: z.boolean().optional(), sampler: samplerSchema.optional() }).optional(),
161
+ physical: z.strictObject({ emissiveLinear: z.tuple([z.number(), z.number(), z.number()]).optional(), emissiveStrength: z.number().min(0).max(100_000).optional(), clearcoat: z.strictObject({ factor: z.number().min(0).max(1), roughness: z.number().min(0).max(1) }).optional(), transmission: z.number().min(0).max(1).optional(), sheen: z.strictObject({ colorLinear: z.tuple([z.number(), z.number(), z.number()]), roughness: z.number().min(0).max(1) }).optional(), anisotropy: z.strictObject({ strength: z.number().min(0).max(1), rotation: z.number().min(0).max(2 * Math.PI) }).optional(), ior: z.number().min(1).max(5).optional() }).optional(),
75
162
  }).superRefine((primitive, context) => {
76
163
  const vertices = primitive.positions.length / 3;
164
+ const hasTexture = primitive.texture !== undefined || primitive.normalTexture !== undefined || primitive.metallicRoughnessTexture !== undefined
165
+ || primitive.occlusionTexture !== undefined || primitive.emissiveTexture !== undefined || primitive.clearcoatTexture !== undefined || primitive.clearcoatRoughnessTexture !== undefined || primitive.clearcoatNormalTexture !== undefined || primitive.transmissionTexture !== undefined || primitive.sheenColorTexture !== undefined || primitive.sheenRoughnessTexture !== undefined || primitive.anisotropyTexture !== undefined;
77
166
  if (!Number.isInteger(vertices)
78
167
  || (primitive.indices === undefined ? vertices % 3 !== 0 : primitive.indices.length % 3 !== 0)
79
168
  || primitive.indices?.some(index => index >= vertices)
80
169
  || (primitive.normals !== undefined && primitive.normals.length !== primitive.positions.length)
81
170
  || (primitive.uvs !== undefined && primitive.uvs.length !== vertices * 2)
82
- || (primitive.texture !== undefined && primitive.uvs === undefined)) {
171
+ || (hasTexture && primitive.uvs === undefined)) {
83
172
  context.addIssue({ code: "custom", message: "Prepared geometry must contain complete triangles, matching attributes, and in-range indices; textured geometry requires UVs." });
84
173
  }
85
174
  try { invertTransform(primitive.matrix); } catch {
@@ -89,7 +178,9 @@ const primitiveSchema = z.strictObject({
89
178
 
90
179
  export const PreparedSpatialAssetSchema = z.discriminatedUnion("kind", [
91
180
  z.strictObject({ kind: z.literal("splat"), assetId: SpatialAssetIdSchema, entityId: SpatialEntityIdSchema, assetManifestSha256: SpatialDigestSchema,
92
- resource: HtmlOverlayDeclaredResourceSchema, facts: SpatialSpzFactsSchema }),
181
+ resource: HtmlOverlayDeclaredResourceSchema, facts: SpatialSpzFactsSchema,
182
+ /** Entity-local axis-aligned enclosure decoded from the SPZ position section; the object-ID proxy derives from it. */
183
+ bounds: SpatialAuditBoundsSchema }),
93
184
  z.strictObject({
94
185
  kind: z.literal("raster"),
95
186
  assetId: SpatialAssetIdSchema,
@@ -107,6 +198,21 @@ export const PreparedSpatialAssetSchema = z.discriminatedUnion("kind", [
107
198
  entityContentSha256: SpatialDigestSchema.optional(),
108
199
  ...textureShape,
109
200
  }),
201
+ z.strictObject({
202
+ kind: z.literal("lut"),
203
+ assetId: SpatialAssetIdSchema,
204
+ assetManifestSha256: SpatialDigestSchema,
205
+ ...textureShape,
206
+ }),
207
+ z.strictObject({
208
+ kind: z.literal("particle-instances"),
209
+ entityId: SpatialEntityIdSchema,
210
+ instanceCount: z.number().int().min(0).max(1_000_000),
211
+ resource: HtmlOverlayDeclaredResourceSchema,
212
+ strideBytes: z.literal(64),
213
+ systemSha256: SpatialDigestSchema,
214
+ timeUs: SpatialTimeUsSchema,
215
+ }),
110
216
  z.strictObject({
111
217
  kind: z.literal("geometry"),
112
218
  assetId: SpatialAssetIdSchema,
@@ -129,11 +235,21 @@ export const SpatialRenderModeSchema = z.discriminatedUnion("kind", [
129
235
  z.strictObject({ kind: z.literal("beauty") }),
130
236
  z.strictObject({ kind: z.literal("object-id"), coverage: SpatialCoverageSchema }),
131
237
  z.strictObject({ kind: z.literal("axial-depth"), coverage: SpatialCoverageSchema }),
238
+ z.strictObject({ kind: z.literal("motion"), coverage: SpatialCoverageSchema, entityId: SpatialEntityIdSchema,
239
+ /** Pixels per frame interval mapped to the packed evidence midpoint range. */
240
+ motionScale: z.number().finite().positive().max(1_000_000) }),
132
241
  ]);
133
242
 
134
243
  export const SpatialOverlayBatchInputSchema = z.strictObject({
135
244
  snapshots: z.array(EvaluatedSpatialSceneSchema).min(1).max(SPATIAL_OVERLAY_LIMITS.frames),
245
+ /**
246
+ * The immediately preceding evaluated sample, retained only to derive exact
247
+ * screen-space velocity for the first frame of this batch. It is never
248
+ * rendered, and its scene digest must match the rendered snapshots.
249
+ */
250
+ previousSnapshot: EvaluatedSpatialSceneSchema.optional(),
136
251
  frameRate: SpatialFrameRateSchema,
252
+ effects: SpatialRenderEffectsBindingSchema.optional(),
137
253
  mode: SpatialRenderModeSchema,
138
254
  executionProfile: HtmlOverlayExecutionProfileSchema.optional(),
139
255
  preparedAssets: z.array(PreparedSpatialAssetSchema).max(SPATIAL_OVERLAY_LIMITS.preparedAssets).default([]),
@@ -144,6 +260,7 @@ export type SpatialRenderMode = DeepReadonly<z.infer<typeof SpatialRenderModeSch
144
260
  export type SpatialOverlayBatchInput = DeepReadonly<z.input<typeof SpatialOverlayBatchInputSchema>>;
145
261
  type PreparedRaster = Extract<PreparedSpatialAsset, { kind: "raster" }>;
146
262
  type PreparedGeometry = Extract<PreparedSpatialAsset, { kind: "geometry" }>;
263
+ type PreparedParticleInstances = Extract<PreparedSpatialAsset, { kind: "particle-instances" }>;
147
264
  type PreparedSplat = Extract<PreparedSpatialAsset, { kind: "splat" }>;
148
265
  type LoweredSplat = Readonly<{ kind: "splat"; entityId: string; key: string; matrix: Mat4 }>;
149
266
  type Material = DeepReadonly<z.infer<typeof SpatialMaterialSchema>>;
@@ -161,6 +278,18 @@ export function spatialVideoRasterContentSha256(entity: Extract<SpatialEntity, {
161
278
  return canonicalJsonSha256({ domain: "slopcamera.spatial-video-binding.v1", assetId: entity.assetId, sourceOffsetUs: entity.sourceOffsetUs, playback: entity.playback });
162
279
  }
163
280
 
281
+ /**
282
+ * The content normalization every splat representation applies: the declared
283
+ * `sourceUp`→Y rotation plus the uniform `metersPerUnit` scale, ahead of the
284
+ * entity's own transform. Beauty rendering and proxy bounds share this map.
285
+ */
286
+ export function spatialSplatContentTransform(interpretation: { readonly metersPerUnit: number; readonly sourceUp: "x" | "y" | "z" }): Mat4 {
287
+ const rotation = interpretation.sourceUp === "x" ? [0, 0, Math.SQRT1_2, Math.SQRT1_2] as const
288
+ : interpretation.sourceUp === "z" ? [-Math.SQRT1_2, 0, 0, Math.SQRT1_2] as const : [0, 0, 0, 1] as const;
289
+ const unit = interpretation.metersPerUnit;
290
+ return composeTransform({ position: [0, 0, 0], rotation, scale: [unit, unit, unit] });
291
+ }
292
+
164
293
  export class SpatialOverlayCapabilityError extends Error {
165
294
  readonly code = "unsupported-capability";
166
295
  constructor(readonly capability: string, message: string) {
@@ -215,11 +344,15 @@ type LoweredMesh = Readonly<{
215
344
  entityId: string;
216
345
  selectionId: number;
217
346
  matrix: Mat4;
347
+ /** Exact previous-sample model matrix for screen-space velocity; present only when the batch derives motion. */
348
+ previousMatrix?: Mat4;
218
349
  placement: SpatialEntity["placement"];
219
350
  geometry: Exclude<Extract<SpatialEntity, { kind: "mesh" }>["geometry"], { kind: "asset" }> | { readonly kind: "prepared"; readonly key: string; readonly primitive: number };
220
351
  material: Material;
221
352
  textureName?: string;
222
353
  textureAlpha?: "straight" | "opaque";
354
+ /** Exact prepared resource name for every PBR map asset used by this mesh. */
355
+ pbrTextures?: Readonly<Record<string, string>>;
223
356
  uvScale: readonly [number, number];
224
357
  uvOffset: readonly [number, number];
225
358
  surfaceScale: readonly [number, number];
@@ -228,15 +361,31 @@ type LoweredMesh = Readonly<{
228
361
  alphaCutoff: number;
229
362
  alphaMode?: "OPAQUE" | "MASK" | "BLEND";
230
363
  linearColor?: readonly [number, number, number];
231
- textureOptions?: Readonly<{ readonly flipY?: boolean; readonly sampler?: DeepReadonly<z.infer<typeof samplerSchema>> }>;
364
+ castShadow?: boolean;
365
+ receiveShadow?: boolean;
366
+ textureOptions?: Readonly<{ readonly flipY?: boolean; readonly sampler?: DeepReadonly<z.infer<typeof samplerSchema>>; readonly uvTransform?: { readonly offset: readonly [number, number]; readonly rotation: number; readonly scale: readonly [number, number] } }>;
367
+ sourcePhysical?: DeepReadonly<NonNullable<z.infer<typeof primitiveSchema>["physical"]>>;
368
+ sourceTextures?: Readonly<Record<string, { readonly name: string; readonly uvTransform?: { readonly offset: readonly [number, number]; readonly rotation: number; readonly scale: readonly [number, number] } }>>;
232
369
  }>;
233
370
  type LoweredLight = Readonly<{
234
371
  kind: "light";
235
372
  entityId: string;
236
373
  matrix: Mat4;
237
- light: "ambient" | "point" | "directional";
374
+ light: "ambient" | "point" | "directional" | "spot";
238
375
  color: string;
239
376
  intensity: number;
377
+ spot?: SpatialSpotLight;
378
+ shadow?: boolean;
379
+ }>;
380
+ /** Equirect sky surface: camera-following background and optional standard-material environment light. */
381
+ type LoweredEnvironment = Readonly<{
382
+ kind: "environment";
383
+ entityId: string;
384
+ textureName: string;
385
+ /** Effective world rotation in XYZW; environment entities are unparented so the authored rotation is world-space. */
386
+ rotation: readonly [number, number, number, number];
387
+ intensity: number;
388
+ role: "background" | "environment" | "both";
240
389
  }>;
241
390
 
242
391
  function unsupported(capability: string, message: string): never {
@@ -245,6 +394,8 @@ function unsupported(capability: string, message: string): never {
245
394
 
246
395
  function preparedKey(asset: PreparedSpatialAsset): string {
247
396
  if (asset.kind === "splat") return `${asset.assetId}:${asset.entityId}:splat`;
397
+ if (asset.kind === "lut") return `lut:${asset.assetId}`;
398
+ if (asset.kind === "particle-instances") return `${asset.systemSha256}:${asset.entityId}:${asset.timeUs}`;
248
399
  return asset.kind === "raster"
249
400
  ? `${asset.assetId}:${asset.entityId}:${asset.timeUs === null ? "static" : String(asset.timeUs)}`
250
401
  : `${asset.assetId}:${asset.entityId}:${asset.nodeIndex === undefined ? "all" : String(asset.nodeIndex)}:${asset.timeUs === null ? "static" : String(asset.timeUs)}`;
@@ -300,6 +451,9 @@ function calibratedCamera(camera: SpatialCamera) {
300
451
  near: camera.projection.near,
301
452
  far: camera.projection.far,
302
453
  kind: camera.projection.kind,
454
+ // Physical values are carried to the renderer payload for deterministic
455
+ // cinematic consumers; calibrated projection remains authoritative.
456
+ ...(camera.lens === undefined ? {} : { lens: camera.lens }),
303
457
  };
304
458
  }
305
459
 
@@ -316,13 +470,36 @@ export function createSpatialOverlayBatch(input: unknown) {
316
470
  const first = request.snapshots[0]!;
317
471
  const width = first.camera.projection.width;
318
472
  const height = first.camera.projection.height;
473
+ if (request.effects !== undefined && request.snapshots.some(snapshot => snapshot.sceneSha256 !== request.effects!.document.sceneSha256)) {
474
+ throw new RangeError("Render effects belong to a different evaluated scene source.");
475
+ }
476
+ const activeEffects = request.mode.kind === "beauty" ? request.effects : undefined;
477
+ const effectSteps = activeEffects?.document.renderPlan.postProcess?.steps ?? [];
478
+ const needsVelocity = request.mode.kind === "motion" || effectSteps.some(step => step.kind === "motion-blur");
479
+ const needsDepth = effectSteps.some(step => step.kind === "depth-of-field");
480
+ if (request.previousSnapshot !== undefined) {
481
+ if (!needsVelocity) throw new RangeError("A previous snapshot is only meaningful when the batch derives motion.");
482
+ if (request.previousSnapshot.sceneSha256 !== first.sceneSha256 || request.previousSnapshot.stateSha256 === first.stateSha256
483
+ || request.previousSnapshot.timeUs >= first.timeUs) {
484
+ throw new RangeError("The previous snapshot must precede this batch on the same scene source.");
485
+ }
486
+ }
487
+ if (request.mode.kind === "motion" && request.snapshots.length < 2 && request.previousSnapshot === undefined) {
488
+ throw new RangeError("Motion evidence requires a leading sample or at least two rendered samples.");
489
+ }
319
490
  const prepared = new Map<string, PreparedSpatialAsset>();
320
491
  const geometries = new Map<string, PreparedGeometry>();
492
+ const particles = new Map<string, PreparedParticleInstances>();
321
493
  const splats = new Map<string, PreparedSplat>();
494
+ const splatResources = new Map<string, HtmlOverlayDeclaredResource>();
322
495
  const resources = new Map<string, HtmlOverlayDeclaredResource>();
323
496
  const textures = new Map<string, { readonly width: number; readonly height: number; readonly alpha: "straight" | "opaque" }>();
497
+ const lutBindings = new Map<string, { readonly resourceName: string; readonly size: number }>();
324
498
  const usedPrepared = new Set<string>();
325
499
  const bindTexture = (texture: NonNullable<Texture>): void => {
500
+ // Motion evidence draws velocity only; prepared raster bindings still count
501
+ // as used through bindAsset, but their pixels are never declared or served.
502
+ if (request.mode.kind === "motion") return;
326
503
  assertRasterResource(texture);
327
504
  const existing = resources.get(texture.resource.name);
328
505
  if (existing !== undefined && canonicalJson(existing) !== canonicalJson(texture.resource)) {
@@ -353,20 +530,78 @@ export function createSpatialOverlayBatch(input: unknown) {
353
530
  resources.set(asset.resource.name, asset.resource);
354
531
  }
355
532
  }
533
+ if (asset.kind === "lut") {
534
+ if (activeEffects === undefined || !activeEffects.document.renderPlan.postProcess?.steps.some(step => step.kind === "lut-grade" && step.assetId === asset.assetId)) {
535
+ throw new RangeError(`Prepared LUT ${asset.assetId} does not bind an active lut-grade step.`);
536
+ }
537
+ const size = asset.height;
538
+ if (asset.width !== size * size || size < 2 || size > 256) {
539
+ throw new RangeError(`Prepared LUT ${asset.assetId} must be an s²×s strip image encoding an s³ grid, with 2 ≤ s ≤ 256.`);
540
+ }
541
+ bindTexture(asset);
542
+ lutBindings.set(asset.assetId, { resourceName: asset.resource.name, size });
543
+ usedPrepared.add(key);
544
+ continue;
545
+ }
546
+ if (asset.kind === "particle-instances") {
547
+ if (activeEffects === undefined || asset.resource.transport !== "fetch" || asset.resource.mediaType !== "application/octet-stream"
548
+ || asset.resource.bytes !== asset.instanceCount * asset.strideBytes
549
+ || !request.snapshots.some(snapshot => snapshot.timeUs === asset.timeUs)
550
+ || !activeEffects.document.particleSystems.some(binding => binding.systemSha256 === asset.systemSha256 && binding.system.entityId === asset.entityId)) {
551
+ throw new RangeError("Prepared particle instances must bind an active system, exact sample, fixed layout, and immutable binary resource.");
552
+ }
553
+ const previous = resources.get(asset.resource.name);
554
+ if (previous !== undefined && canonicalJson(previous) !== canonicalJson(asset.resource)) throw new RangeError("Particle resource names must identify exact immutable bytes.");
555
+ particles.set(key, asset);
556
+ resources.set(asset.resource.name, asset.resource);
557
+ usedPrepared.add(key);
558
+ }
356
559
  if (asset.kind === "splat") {
357
560
  const expected = spatialSpzAllocationBounds(asset.facts.splats, asset.resource.bytes, asset.facts.decompressedBytes);
358
561
  if (asset.resource.mediaType !== "application/octet-stream" || asset.resource.bytes < 18 || expected.gpuBytesBound !== asset.facts.gpuBytesBound || expected.hostBytesBound !== asset.facts.hostBytesBound) throw new RangeError("Prepared SPZ allocation or binary resource evidence is invalid.");
359
- const previous = resources.get(asset.resource.name);
360
- if (previous !== undefined && canonicalJson(previous) !== canonicalJson(asset.resource)) throw new RangeError("Splat resource names must identify exact immutable bytes.");
361
- splats.set(key, asset); resources.set(asset.resource.name, asset.resource);
562
+ const previous = splatResources.get(asset.resource.name);
563
+ const declared = resources.get(asset.resource.name);
564
+ if ((previous !== undefined && canonicalJson(previous) !== canonicalJson(asset.resource))
565
+ || (declared !== undefined && canonicalJson(declared) !== canonicalJson(asset.resource))) throw new RangeError("Splat resource names must identify exact immutable bytes.");
566
+ splatResources.set(asset.resource.name, asset.resource);
567
+ splats.set(key, asset);
568
+ // Object-ID lowers only the bounding-box proxy: the SPZ payload is never
569
+ // declared, served, decoded, or allocated for that mode.
570
+ if (request.mode.kind === "beauty") resources.set(asset.resource.name, asset.resource);
571
+ }
572
+ }
573
+ if (activeEffects !== undefined) {
574
+ for (const binding of activeEffects.document.particleSystems) {
575
+ for (const snapshot of request.snapshots) {
576
+ const key = `${binding.systemSha256}:${binding.system.entityId}:${snapshot.timeUs}`;
577
+ if (!particles.has(key)) throw new RangeError(`Particle system ${binding.system.entityId} has no exact prepared buffer at ${snapshot.timeUs}.`);
578
+ }
579
+ }
580
+ for (const step of effectSteps) {
581
+ if (step.kind === "lut-grade" && !lutBindings.has(step.assetId)) {
582
+ throw new RangeError(`lut-grade step has no exact prepared LUT for ${step.assetId}.`);
583
+ }
362
584
  }
363
585
  }
586
+ /** Screen-space velocity derives from exact per-sample transforms; the first frame of a batch falls back to its leading snapshot, then to zero motion. */
587
+ const previousModelMatrix = (entityId: string, currentWorld: Mat4, previousSnapshot: EvaluatedSpatialScene | undefined, local?: Mat4, previousLocal?: Mat4): Mat4 => {
588
+ const world = previousSnapshot === undefined ? currentWorld
589
+ : previousSnapshot.entities.find(entry => entry.entity.entityId === entityId)?.worldMatrix ?? currentWorld;
590
+ return local === undefined ? world : multiplyTransforms(world, previousLocal ?? local);
591
+ };
592
+ const previousPrimitiveMatrix = (baseKey: string, primitive: number, previousTimeUs: number | undefined, fallback: Mat4): Mat4 => {
593
+ if (previousTimeUs === undefined) return fallback;
594
+ const asset = prepared.get(`${baseKey}:${previousTimeUs}`);
595
+ return asset?.kind === "geometry" && asset.primitives[primitive] !== undefined ? asset.primitives[primitive].matrix : fallback;
596
+ };
364
597
  const frameEvidence: Array<{
365
598
  readonly sceneSha256: string; readonly stateSha256: string; readonly viewSha256: string; readonly timeUs: number;
366
599
  readonly camera: SpatialCamera;
367
600
  readonly objects: ReadonlyArray<{ readonly entityId: string; readonly selectionId: number; readonly representation: string; readonly placement: "world" | "view"; readonly assetManifestSha256?: string }>;
368
601
  }> = [];
369
- const frames = request.snapshots.map((snapshot: EvaluatedSpatialScene) => {
602
+ const frames = request.snapshots.map((snapshot: EvaluatedSpatialScene, snapshotIndex: number) => {
603
+ const previousSnapshot = snapshotIndex > 0 ? request.snapshots[snapshotIndex - 1]! : request.previousSnapshot;
604
+ if (snapshot.fog?.kind === "height") unsupported("height-fog", "Height-dependent fog is not representable by Three FogExp2; use linear fog until a qualified height-fog lowering exists.");
370
605
  if (snapshot.camera.projection.width !== width || snapshot.camera.projection.height !== height) {
371
606
  throw new RangeError("A spatial overlay batch requires identical calibrated output dimensions.");
372
607
  }
@@ -375,13 +610,14 @@ export function createSpatialOverlayBatch(input: unknown) {
375
610
  if (manifests.size !== snapshot.assets.length) throw new RangeError("Snapshot asset IDs must be unique.");
376
611
  const ids = new Set<string>();
377
612
  const selections = new Set<number>();
378
- const objects: (LoweredMesh | LoweredLight | LoweredSplat)[] = [];
613
+ const objects: (LoweredMesh | LoweredLight | LoweredSplat | LoweredEnvironment)[] = [];
379
614
  const evidence: Array<{ entityId: string; selectionId: number; representation: string; placement: "world" | "view"; assetManifestSha256?: string }> = [];
380
615
  const bindAsset = (assetId: string, key: string): PreparedSpatialAsset => {
381
616
  const manifest = manifests.get(assetId);
382
617
  if (manifest === undefined) throw new RangeError(`Snapshot has no manifest for ${assetId}.`);
383
618
  const asset = prepared.get(key);
384
619
  if (asset === undefined) unsupported(`prepare-${manifest.interpretation.kind}`, `No exact prepared representation for ${key}.`);
620
+ if (asset.kind === "particle-instances") throw new RangeError(`Prepared particle binding cannot substitute scene asset ${assetId}.`);
385
621
  if (asset.assetManifestSha256 !== manifestDigests[assetId]) {
386
622
  throw new RangeError(`Prepared asset manifest identity is stale for ${assetId}.`);
387
623
  }
@@ -394,29 +630,71 @@ export function createSpatialOverlayBatch(input: unknown) {
394
630
  ids.add(entity.entityId); selections.add(entry.selectionId);
395
631
  invertTransform(entry.worldMatrix);
396
632
  if (entity.kind === "splat") {
397
- if (request.executionProfile !== "three-spark-webgl2-hardware-v1") unsupported("splat-profile", "Splat rendering requires the explicit Three/Spark hardware profile.");
398
- if (request.mode.kind !== "beauty") unsupported("splat-aov", "Splat object-ID and axial-depth are unsupported; retained colliders are approximate evidence, not pixel truth.");
399
- if (entity.placement.kind !== "world" || snapshot.camera.projection.kind !== "perspective") unsupported("splat-camera", "The initial Spark profile requires world placement and a perspective camera.");
400
- const key = `${entity.assetId}:${entity.entityId}:splat`, preparedSplat = bindAsset(entity.assetId, key), manifest = manifests.get(entity.assetId)!;
633
+ const key = `${entity.assetId}:${entity.entityId}:splat`;
634
+ if (request.mode.kind === "beauty") {
635
+ if (request.executionProfile !== "three-spark-webgl2-hardware-v1") unsupported("splat-profile", "Splat rendering requires the explicit Three/Spark hardware profile.");
636
+ if (entity.placement.kind !== "world" || snapshot.camera.projection.kind !== "perspective") unsupported("splat-camera", "The initial Spark profile requires world placement and a perspective camera.");
637
+ const preparedSplat = bindAsset(entity.assetId, key), manifest = manifests.get(entity.assetId)!;
638
+ if (preparedSplat.kind !== "splat" || manifest.interpretation.kind !== "splat" || manifest.interpretation.format !== "spz" || preparedSplat.resource.sha256 !== manifest.payload.sha256 || preparedSplat.resource.bytes !== manifest.payload.bytes) throw new RangeError("Splat resource does not match the exact source payload.");
639
+ const m = entry.worldMatrix, lengths = [Math.hypot(m[0]!, m[1]!, m[2]!), Math.hypot(m[4]!, m[5]!, m[6]!), Math.hypot(m[8]!, m[9]!, m[10]!)];
640
+ if (Math.max(...lengths) - Math.min(...lengths) > Math.max(...lengths) * 1e-6) unsupported("splat-transform", "The initial Spark profile requires uniform world scale.");
641
+ for (const [left, right] of [[0, 4], [0, 8], [4, 8]] as const) if (Math.abs(m[left]! * m[right]! + m[left + 1]! * m[right + 1]! + m[left + 2]! * m[right + 2]!) > lengths[0]! ** 2 * 1e-6) unsupported("splat-transform", "The initial Spark profile does not accept sheared world transforms.");
642
+ if (entry.visible) objects.push({ kind: "splat", entityId: entity.entityId, key, matrix: multiplyTransforms(entry.worldMatrix, spatialSplatContentTransform(manifest.interpretation)) });
643
+ evidence.push({ entityId: entity.entityId, selectionId: entry.selectionId, representation: "spz-static-radiance; authored-wrapper-id; no-depth-or-object-id", placement: "world", assetManifestSha256: preparedSplat.assetManifestSha256 });
644
+ continue;
645
+ }
646
+ if (request.mode.kind !== "object-id" && request.mode.kind !== "motion") unsupported("splat-aov", "Splat axial-depth is unsupported; retained colliders are approximate evidence, not pixel truth.");
647
+ // Object-ID and motion lower the splat's bounding-box proxy under its
648
+ // normal selection code — approximate coverage, never splat pixel
649
+ // truth. The box derives from the prepared decoded-position bounds, so
650
+ // a splat without trusted bounds has no representation to bind.
651
+ const preparedSplat = bindAsset(entity.assetId, key), manifest = manifests.get(entity.assetId)!;
401
652
  if (preparedSplat.kind !== "splat" || manifest.interpretation.kind !== "splat" || manifest.interpretation.format !== "spz" || preparedSplat.resource.sha256 !== manifest.payload.sha256 || preparedSplat.resource.bytes !== manifest.payload.bytes) throw new RangeError("Splat resource does not match the exact source payload.");
402
- const m = entry.worldMatrix, lengths = [Math.hypot(m[0]!, m[1]!, m[2]!), Math.hypot(m[4]!, m[5]!, m[6]!), Math.hypot(m[8]!, m[9]!, m[10]!)];
403
- if (Math.max(...lengths) - Math.min(...lengths) > Math.max(...lengths) * 1e-6) unsupported("splat-transform", "The initial Spark profile requires uniform world scale.");
404
- for (const [left, right] of [[0, 4], [0, 8], [4, 8]] as const) if (Math.abs(m[left]! * m[right]! + m[left + 1]! * m[right + 1]! + m[left + 2]! * m[right + 2]!) > lengths[0]! ** 2 * 1e-6) unsupported("splat-transform", "The initial Spark profile does not accept sheared world transforms.");
405
- const rotation = manifest.interpretation.sourceUp === "x" ? [0, 0, Math.SQRT1_2, Math.SQRT1_2] as const : manifest.interpretation.sourceUp === "z" ? [-Math.SQRT1_2, 0, 0, Math.SQRT1_2] as const : [0, 0, 0, 1] as const;
406
- const unit = manifest.interpretation.metersPerUnit;
407
- if (entry.visible) objects.push({ kind: "splat", entityId: entity.entityId, key, matrix: multiplyTransforms(entry.worldMatrix, composeTransform({ position: [0, 0, 0], rotation, scale: [unit, unit, unit] })) });
408
- evidence.push({ entityId: entity.entityId, selectionId: entry.selectionId, representation: "spz-static-radiance; authored-wrapper-id; no-depth-or-object-id", placement: "world", assetManifestSha256: preparedSplat.assetManifestSha256 });
653
+ if (entry.visible && entity.placement.kind === "world") {
654
+ const bounds = preparedSplat.bounds;
655
+ const center: [number, number, number] = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
656
+ const size: [number, number, number] = [bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]];
657
+ const material: Material = { kind: "unlit", color: "#ffffff", opacity: 1 };
658
+ assertCoverage(request.mode, material, undefined);
659
+ const proxyLocal = composeTransform({ position: center, rotation: [0, 0, 0, 1], scale: [1, 1, 1] });
660
+ objects.push({ kind: "mesh", entityId: entity.entityId, selectionId: entry.selectionId,
661
+ matrix: multiplyTransforms(entry.worldMatrix, proxyLocal),
662
+ ...(needsVelocity ? { previousMatrix: previousModelMatrix(entity.entityId, entry.worldMatrix, previousSnapshot, proxyLocal) } : {}),
663
+ placement: entity.placement, geometry: { kind: "box", size }, material,
664
+ uvScale: [1, 1], uvOffset: [0, 0], surfaceScale: [1, 1], flipViewUv: false, doubleSided: true, alphaCutoff: 0 });
665
+ evidence.push({ entityId: entity.entityId, selectionId: entry.selectionId, representation: SPATIAL_SPLAT_PROXY_REPRESENTATION, placement: "world", assetManifestSha256: preparedSplat.assetManifestSha256 });
666
+ }
409
667
  continue;
410
668
  }
411
669
  if (entity.kind === "group") continue;
412
670
  if (entity.kind === "light") {
413
671
  if (entity.placement.kind !== "world") unsupported("view-light", "Lights require world placement.");
414
- if (entry.visible) objects.push({ kind: "light", entityId: entity.entityId, matrix: entry.worldMatrix, light: entity.light, color: entity.color, intensity: entity.intensity });
672
+ if (entry.visible) objects.push({
673
+ kind: "light", entityId: entity.entityId, matrix: entry.worldMatrix, light: entity.light, color: entity.color, intensity: entity.intensity,
674
+ ...(entity.spot === undefined ? {} : { spot: entity.spot }),
675
+ ...(entity.shadow === undefined ? {} : { shadow: entity.shadow }),
676
+ });
677
+ continue;
678
+ }
679
+ if (entity.kind === "environment") {
680
+ // The equirect raster is bound for every frame even when hidden: the
681
+ // prepared batch and auxiliary passes must see identical bindings.
682
+ const key = `${entity.assetId}:${entity.entityId}:static`;
683
+ const asset = bindAsset(entity.assetId, key);
684
+ if (asset.kind !== "raster" || manifests.get(entity.assetId)!.interpretation.kind !== "image") {
685
+ unsupported("prepared-environment-kind", "Environment entities require a prepared image raster.");
686
+ }
687
+ bindTexture(asset);
688
+ if (entry.visible) objects.push({ kind: "environment", entityId: entity.entityId, textureName: asset.resource.name,
689
+ rotation: entity.transform.rotation, intensity: entity.intensity, role: entity.role });
415
690
  continue;
416
691
  }
417
692
  // Capability checks include invisible/off-camera entities, so changing
418
693
  // visibility or cameras cannot discover an unplanned loader during render.
419
- const base = { kind: "mesh" as const, entityId: entity.entityId, selectionId: entry.selectionId, matrix: entry.worldMatrix, placement: entity.placement };
694
+ const base = { kind: "mesh" as const, entityId: entity.entityId, selectionId: entry.selectionId, matrix: entry.worldMatrix, placement: entity.placement,
695
+ ...(needsVelocity ? { previousMatrix: previousModelMatrix(entity.entityId, entry.worldMatrix, previousSnapshot) } : {}),
696
+ ...(entity.kind === "mesh" && entity.castShadow !== undefined ? { castShadow: entity.castShadow } : {}),
697
+ ...(entity.kind === "mesh" && entity.receiveShadow !== undefined ? { receiveShadow: entity.receiveShadow } : {}) };
420
698
  const unitFit = { uvScale: [1, 1] as const, uvOffset: [0, 0] as const, surfaceScale: [1, 1] as const, flipViewUv: false, doubleSided: true, alphaCutoff: 0 };
421
699
  let meshes: LoweredMesh[];
422
700
  let representation: string;
@@ -437,7 +715,9 @@ export function createSpatialOverlayBatch(input: unknown) {
437
715
  representation = asset.timeUs === null ? "prepared-static-triangle-mesh" : "prepared-evaluated-triangle-mesh";
438
716
  const materialMode = entity.geometry.materialMode;
439
717
  meshes = asset.primitives.map((primitive, index) => {
718
+ const sourceTextureSlots = ["normalTexture", "metallicRoughnessTexture", "occlusionTexture", "emissiveTexture", "clearcoatTexture", "clearcoatRoughnessTexture", "clearcoatNormalTexture", "transmissionTexture", "sheenColorTexture", "sheenRoughnessTexture", "anisotropyTexture"] as const;
440
719
  if (primitive.texture !== undefined) bindTexture(primitive.texture);
720
+ for (const slot of sourceTextureSlots) if (primitive[slot] !== undefined) bindTexture(primitive[slot]);
441
721
  const material = primitive.material ?? entity.material;
442
722
  if (materialMode === "source" && primitive.material === undefined) throw new RangeError("Source-material geometry requires the exact resolved source material for every primitive.");
443
723
  if (materialMode !== "source" && (primitive.material !== undefined || primitive.texture !== undefined || primitive.linearColor !== undefined)) {
@@ -445,17 +725,42 @@ export function createSpatialOverlayBatch(input: unknown) {
445
725
  }
446
726
  assertCoverage(request.mode, material, primitive.texture, primitive.alphaMode);
447
727
  return { ...base, ...unitFit, geometry: { kind: "prepared", key, primitive: index }, material,
728
+ ...(needsVelocity ? { previousMatrix: previousModelMatrix(entity.entityId, entry.worldMatrix, previousSnapshot, primitive.matrix,
729
+ previousPrimitiveMatrix(baseKey, index, previousSnapshot?.timeUs, primitive.matrix)) } : {}),
448
730
  doubleSided: primitive.doubleSided ?? true, alphaCutoff: primitive.alphaCutoff ?? 0,
449
731
  ...(primitive.alphaMode === undefined ? {} : { alphaMode: primitive.alphaMode }),
450
732
  ...(primitive.linearColor === undefined ? {} : { linearColor: primitive.linearColor }),
733
+ ...(primitive.physical === undefined ? {} : { sourcePhysical: primitive.physical }),
734
+ ...(sourceTextureSlots.every(slot => primitive[slot] === undefined) ? {} : { sourceTextures: Object.fromEntries(sourceTextureSlots.flatMap(slot => primitive[slot] === undefined ? [] : [[slot, { name: primitive[slot]!.resource.name, ...(primitive[slot]!.uvTransform === undefined ? {} : { uvTransform: primitive[slot]!.uvTransform }) }]])) }),
451
735
  ...(primitive.texture === undefined ? {} : { textureName: primitive.texture.resource.name, textureAlpha: primitive.texture.alpha,
452
736
  textureOptions: { ...(primitive.texture.flipY === undefined ? {} : { flipY: primitive.texture.flipY }),
453
- ...(primitive.texture.sampler === undefined ? {} : { sampler: primitive.texture.sampler }) } }) };
737
+ ...(primitive.texture.sampler === undefined ? {} : { sampler: primitive.texture.sampler }), ...(primitive.texture.uvTransform === undefined ? {} : { uvTransform: primitive.texture.uvTransform }) } }) };
454
738
  });
455
739
  } else {
456
- assertCoverage(request.mode, entity.material, undefined);
457
- representation = `primitive-${entity.geometry.kind}`;
458
- meshes = [{ ...base, ...unitFit, geometry: entity.geometry, material: entity.material }];
740
+ let mapRaster: PreparedRaster | undefined;
741
+ const materialMap = entity.material.kind !== "pbr" ? entity.material.map : undefined;
742
+ const pbrTextures: Record<string, string> = {};
743
+ const mapIds = entity.material.kind === "pbr" ? pbrMaterialMapAssetIds(entity.material) : materialMap === undefined ? [] : [materialMap];
744
+ let mapDimensions: readonly [number, number] | undefined;
745
+ for (const mapId of mapIds) {
746
+ const key = `${mapId}:${entity.entityId}:static`;
747
+ const asset = bindAsset(mapId, key);
748
+ if (asset.kind !== "raster" || manifests.get(mapId)!.interpretation.kind !== "image") unsupported("prepared-material-map", "Material maps require a prepared image raster.");
749
+ bindTexture(asset);
750
+ const dimensions = [asset.width, asset.height] as const;
751
+ if (mapDimensions !== undefined && (dimensions[0] !== mapDimensions[0] || dimensions[1] !== mapDimensions[1])) throw new RangeError("PBR maps must have identical decoded dimensions.");
752
+ mapDimensions = dimensions;
753
+ if (entity.material.kind === "pbr") {
754
+ pbrTextures[mapId] = asset.resource.name;
755
+ if (entity.material.baseColorMap?.assetId === mapId) mapRaster = asset;
756
+ } else mapRaster = asset;
757
+ assetManifestSha256 = asset.assetManifestSha256;
758
+ }
759
+ assertCoverage(request.mode, entity.material, mapRaster);
760
+ representation = `primitive-${entity.geometry.kind}${mapIds.length === 0 ? "" : "-textured"}`;
761
+ meshes = [{ ...base, ...unitFit, geometry: entity.geometry, material: entity.material,
762
+ ...(Object.keys(pbrTextures).length === 0 ? {} : { pbrTextures }),
763
+ ...(mapRaster === undefined ? {} : { textureName: mapRaster.resource.name, textureAlpha: mapRaster.alpha }) }];
459
764
  }
460
765
  } else {
461
766
  const assetId = entity.kind === "text" ? entity.fontAssetId : entity.assetId;
@@ -487,15 +792,37 @@ export function createSpatialOverlayBatch(input: unknown) {
487
792
  evidence.push({ entityId: entity.entityId, selectionId: entry.selectionId, representation, placement: entity.placement.kind,
488
793
  ...(assetManifestSha256 === undefined ? {} : { assetManifestSha256 }) });
489
794
  }
795
+ const frameParticles = activeEffects?.document.particleSystems.flatMap(binding => {
796
+ const target = snapshot.entities.find(entry => entry.entity.entityId === binding.system.entityId);
797
+ if (target === undefined) throw new RangeError(`Particle system target ${binding.system.entityId} is absent from the evaluated scene.`);
798
+ if (target.entity.placement.kind !== "world") unsupported("particle-placement", "Particle systems require world-placed target entities.");
799
+ const key = `${binding.systemSha256}:${binding.system.entityId}:${snapshot.timeUs}`;
800
+ const preparedInstances = particles.get(key)!;
801
+ return target.visible && preparedInstances.instanceCount > 0 ? [{ key, entityId: binding.system.entityId, matrix: target.worldMatrix,
802
+ ...(needsVelocity ? { previousMatrix: previousModelMatrix(binding.system.entityId, target.worldMatrix, previousSnapshot) } : {}) }] : [];
803
+ }) ?? [];
804
+ if (objects.reduce((count, object) => count + (object.kind === "environment" ? 1 : 0), 0) > 1) {
805
+ throw new RangeError("A frame may show at most one visible environment entity; gate alternates through authored visibility.");
806
+ }
490
807
  const meshes = objects.filter((object): object is LoweredMesh => object.kind === "mesh");
491
- if (splats.size > 0 && meshes.some(mesh => mesh.placement.kind === "world" && (mesh.material.opacity < 1 || mesh.alphaMode === "BLEND" || mesh.textureAlpha === "straight" && mesh.alphaCutoff === 0))) unsupported("splat-transparency", "The initial world profile supports opaque 3D meshes and view overlays; interleaved transparent mesh/splat sorting is not qualified.");
808
+ // Transparency sorting is a Spark beauty-pipeline constraint; object-ID
809
+ // proxies draw as ordinary opaque-coded meshes and never invoke it.
810
+ if (request.mode.kind === "beauty" && splats.size > 0 && meshes.some(mesh => mesh.placement.kind === "world" && (mesh.material.opacity < 1 || mesh.alphaMode === "BLEND" || mesh.textureAlpha === "straight" && mesh.alphaCutoff === 0))) unsupported("splat-transparency", "The initial world profile supports opaque 3D meshes and view overlays; interleaved transparent mesh/splat sorting is not qualified.");
492
811
  const triangles = meshes.reduce((sum, mesh) => sum + geometryTriangleCount(mesh, geometries), 0);
493
812
  if (meshes.length > SPATIAL_OVERLAY_LIMITS.drawCallsPerFrame || triangles > SPATIAL_OVERLAY_LIMITS.trianglesPerFrame) {
494
813
  throw new RangeError("Spatial overlay exceeds its per-frame draw-call or triangle budget.");
495
814
  }
815
+ if (request.mode.kind === "motion") {
816
+ const entityId = request.mode.entityId;
817
+ if (!meshes.some(mesh => mesh.entityId === entityId) && !frameParticles.some(item => item.entityId === entityId)) {
818
+ throw new RangeError(`Motion target ${entityId} has no renderable velocity representation at ${snapshot.timeUs}.`);
819
+ }
820
+ }
496
821
  frameEvidence.push({ sceneSha256: snapshot.sceneSha256, stateSha256: snapshot.stateSha256, viewSha256: snapshot.viewSha256,
497
822
  timeUs: snapshot.timeUs, camera: snapshot.camera, objects: evidence });
498
- return { camera: calibratedCamera(snapshot.camera), objects, ...(request.executionProfile === "three-spark-webgl2-hardware-v1" ? { timeUs: snapshot.timeUs } : {}) };
823
+ return { camera: calibratedCamera(snapshot.camera), timeUs: snapshot.timeUs,
824
+ ...(needsVelocity ? { previousCamera: previousSnapshot === undefined ? calibratedCamera(snapshot.camera) : calibratedCamera(previousSnapshot.camera) } : {}),
825
+ objects, particles: frameParticles, ...(snapshot.fog === undefined ? {} : { fog: snapshot.fog }) };
499
826
  });
500
827
  if (usedPrepared.size !== prepared.size) throw new RangeError("Prepared assets must be referenced by this exact batch; unused bindings are rejected.");
501
828
  const declaredResources = HtmlOverlayDeclaredResourcesSchema.parse([...resources.values()]);
@@ -510,8 +837,14 @@ export function createSpatialOverlayBatch(input: unknown) {
510
837
  const payload = {
511
838
  mode: request.mode,
512
839
  frames,
840
+ ...(activeEffects === undefined ? {} : { effects: {
841
+ documentSha256: activeEffects.documentSha256,
842
+ renderPlanSha256: activeEffects.document.renderPlanSha256,
843
+ steps: effectSteps.map(step => step.kind === "lut-grade" ? { ...step, lut: lutBindings.get(step.assetId)! } : step),
844
+ } }),
513
845
  geometry: Object.fromEntries([...geometries].filter(([, asset]) => asset.resource === undefined).map(([key, asset]) => [key, asset.primitives])),
514
846
  geometryResources: [...geometries].filter(([, asset]) => asset.resource !== undefined).map(([key, asset]) => ({ key, resource: asset.resource!, url: htmlOverlayAssetLocalUrl(asset.resource!) })),
847
+ particleBuffers: [...particles].map(([key, asset]) => ({ key, instanceCount: asset.instanceCount, resource: asset.resource, strideBytes: asset.strideBytes, url: htmlOverlayAssetLocalUrl(asset.resource) })),
515
848
  textures: declaredResources.filter(resource => textures.has(resource.name)).map(resource => ({ ...textures.get(resource.name)!, name: resource.name, url: htmlOverlayAssetLocalUrl(resource) })),
516
849
  ...(request.executionProfile === "three-spark-webgl2-hardware-v1" ? { splatKernel, splats: [...splats].map(([key, asset]) => ({ key, ...asset, url: htmlOverlayAssetLocalUrl(asset.resource) })) } : {}),
517
850
  };
@@ -519,7 +852,9 @@ export function createSpatialOverlayBatch(input: unknown) {
519
852
  // 32 B/pixel conservatively covers the RGBA16F/depth beauty target and the
520
853
  // default RGBA8/depth canvas plus renderer framebuffer slack. Driver/process
521
854
  // and platform compositor overhead remain outside this allocation accounting.
522
- if (splats.size > 0 && (totalSplats > SPATIAL_SPLAT_LIMITS.splats || [...splats.values()].reduce((sum, asset) => sum + asset.facts.gpuBytesBound, width * height * 32) > SPATIAL_SPLAT_LIMITS.gpuBytes || [...splats.values()].reduce((sum, asset) => sum + asset.facts.hostBytesBound, 0) > SPATIAL_SPLAT_LIMITS.hostBytes)) throw new RangeError("Prepared world exceeds its aggregate splat/GPU/host allocation bounds.");
855
+ // The bound guards Spark's beauty allocations only — object-ID proxies never
856
+ // load or allocate splat payloads.
857
+ if (request.mode.kind === "beauty" && splats.size > 0 && (totalSplats > SPATIAL_SPLAT_LIMITS.splats || [...splats.values()].reduce((sum, asset) => sum + asset.facts.gpuBytesBound, width * height * 32) > SPATIAL_SPLAT_LIMITS.gpuBytes || [...splats.values()].reduce((sum, asset) => sum + asset.facts.hostBytesBound, 0) > SPATIAL_SPLAT_LIMITS.hostBytes)) throw new RangeError("Prepared world exceeds its aggregate splat/GPU/host allocation bounds.");
523
858
  const sparkProfile = request.executionProfile === "three-spark-webgl2-hardware-v1";
524
859
  if (sparkProfile && request.mode.kind !== "beauty") unsupported("splat-aov", "The initial Spark profile renders beauty only.");
525
860
  const libraries = sparkProfile ? ["@sparkjsdev/spark", "three", "three/addons/postprocessing/Pass.js"] as const : ["three"] as const;
@@ -530,9 +865,44 @@ export function createSpatialOverlayBatch(input: unknown) {
530
865
  html, libraries, parameters: {}, resources: declaredResources, seed: 0,
531
866
  timing: { fps: 1, durationUs: frames.length * 1_000_000 },
532
867
  });
868
+ const preparation: Array<{
869
+ key: string; assetManifestSha256?: string; instanceCount?: number; resourceSha256?: string; systemSha256?: string; timeUs?: number;
870
+ sourceTimeUs?: number; sourceFrameIndex?: number; sourcePresentationTimeUs?: number; sourcePts?: number;
871
+ sourceTimeBase?: { numerator: string; denominator: string }; sourceTimestamp?: string; sourceExactTimeUs?: { numerator: string; denominator: string };
872
+ }> = request.preparedAssets.map(asset => asset.kind === "particle-instances" ? {
873
+ key: preparedKey(asset), instanceCount: asset.instanceCount, resourceSha256: asset.resource.sha256,
874
+ systemSha256: asset.systemSha256, timeUs: asset.timeUs,
875
+ } : { key: preparedKey(asset), assetManifestSha256: asset.assetManifestSha256,
876
+ ...(asset.kind === "raster" && asset.sourceTimeUs !== undefined ? { sourceTimeUs: asset.sourceTimeUs } : {}),
877
+ ...(asset.kind === "raster" && asset.sourceFrameIndex !== undefined ? { sourceFrameIndex: asset.sourceFrameIndex } : {}),
878
+ ...(asset.kind === "raster" && asset.sourcePresentationTimeUs !== undefined ? { sourcePresentationTimeUs: asset.sourcePresentationTimeUs } : {}),
879
+ ...(asset.kind === "raster" && asset.sourcePts !== undefined ? { sourcePts: asset.sourcePts } : {}),
880
+ ...(asset.kind === "raster" && asset.sourceTimeBase !== undefined ? { sourceTimeBase: asset.sourceTimeBase } : {}),
881
+ ...(asset.kind === "raster" && asset.sourceTimestamp !== undefined ? { sourceTimestamp: asset.sourceTimestamp } : {}),
882
+ ...(asset.kind === "raster" && asset.sourceExactTimeUs !== undefined ? { sourceExactTimeUs: asset.sourceExactTimeUs } : {}),
883
+ });
533
884
  const metadataValue = {
534
885
  kind: "slopcamera.spatial-overlay-batch", schemaVersion: 1,
535
886
  renderer: "three-webgl2-snapshot-v1", mode: request.mode,
887
+ ...(request.effects === undefined ? {} : { effects: {
888
+ applied: activeEffects !== undefined,
889
+ documentSha256: request.effects.documentSha256,
890
+ renderPlanSha256: request.effects.document.renderPlanSha256,
891
+ stepKinds: request.effects.document.renderPlan.postProcess?.steps.map(step => step.kind) ?? [],
892
+ ...(activeEffects === undefined ? {} : { postProcess: {
893
+ pipeline: "ordered-fullscreen-passes; premultiplied-linear-half-float-source; straight-alpha-working-domain; srgb-premultiplied-output",
894
+ ...(lutBindings.size === 0 ? {} : { lutDomain: "linear-clamped-0..1; strip-s2-by-s-3d-lut; two-slice-linear",
895
+ luts: Object.fromEntries([...lutBindings].map(([assetId, binding]) => [assetId, binding])) }),
896
+ ...(needsDepth ? { depthSource: "beauty-depth-buffer; world-surfaces-only; view-overlays-unchanged" } : {}),
897
+ ...(needsVelocity ? { velocitySource: "per-sample-rigid-transform-delta; previous-sample-or-leading-snapshot; first-sample-zero" } : {}),
898
+ } }),
899
+ } }),
900
+ ...(request.mode.kind === "motion" ? { motion: {
901
+ entityId: request.mode.entityId, motionScale: request.mode.motionScale,
902
+ encoding: "rg16un; R,G=x hi/lo; B,A=y hi/lo; alpha=hit",
903
+ source: "per-sample-rigid-transform-delta; previous-sample-or-leading-snapshot; first-sample-zero",
904
+ packing: "velocity_px/motionScale*0.5+0.5 clamped to [0,1]; 16-bit unsigned per axis",
905
+ } } : {}),
536
906
  ...(request.executionProfile === undefined ? {} : { executionProfile: request.executionProfile }),
537
907
  ...(sparkProfile ? { splatProfile: { adapter: "spark-2.1.0-spz-v2-v3-ext-v1", kernel: splatKernel, lod: false, sorting: "await-explicit-camera-update", readback: "synchronous-exact-MRT-buffer-before-worker-sort", color: "srgb-radiance-to-linear", depth: "unsupported", objectId: "unsupported", collider: "approximate-retained-only", splats: totalSplats } } : {}),
538
908
  frameRate: request.frameRate,
@@ -540,6 +910,7 @@ export function createSpatialOverlayBatch(input: unknown) {
540
910
  color: request.mode.kind === "beauty"
541
911
  ? { source: "srgb", working: "linear-srgb", compositing: "linear-half-float-premultiplied", output: "srgb", alpha: "straight-png", toneMapping: "none" }
542
912
  : { source: "rgba8-data", working: "rgba8-data", output: "rgba8-data", alpha: "binary-validity", toneMapping: "none" },
913
+ ...(request.mode.kind === "beauty" ? { shadows: SPATIAL_SHADOW_POLICY } : {}),
543
914
  rasterization: { antialias: false, sampling: "single-pixel-center", surfaces: "authored-primitives-double-sided; prepared-mesh-explicit-sidedness", transparentOrdering: "three-object-sort" },
544
915
  selectionEncoding: { channels: "RGB-big-endian-uint24", noHit: [0, 0, 0, 0] },
545
916
  depthEncoding: {
@@ -550,15 +921,7 @@ export function createSpatialOverlayBatch(input: unknown) {
550
921
  },
551
922
  cameraConvention: "camera-to-world; column-major; XYZW; local-minus-Z; top-left-image-boundary; pixel-centers-i+0.5,j+0.5",
552
923
  viewConvention: "top-left-image-boundary; X-right,Y-down; normalized-XY-scaled-by-output-dimensions; order-after-world",
553
- preparation: request.preparedAssets.map(asset => ({ key: preparedKey(asset), assetManifestSha256: asset.assetManifestSha256,
554
- ...(asset.kind === "raster" && asset.sourceTimeUs !== undefined ? { sourceTimeUs: asset.sourceTimeUs } : {}),
555
- ...(asset.kind === "raster" && asset.sourceFrameIndex !== undefined ? { sourceFrameIndex: asset.sourceFrameIndex } : {}),
556
- ...(asset.kind === "raster" && asset.sourcePresentationTimeUs !== undefined ? { sourcePresentationTimeUs: asset.sourcePresentationTimeUs } : {}),
557
- ...(asset.kind === "raster" && asset.sourcePts !== undefined ? { sourcePts: asset.sourcePts } : {}),
558
- ...(asset.kind === "raster" && asset.sourceTimeBase !== undefined ? { sourceTimeBase: asset.sourceTimeBase } : {}),
559
- ...(asset.kind === "raster" && asset.sourceTimestamp !== undefined ? { sourceTimestamp: asset.sourceTimestamp } : {}),
560
- ...(asset.kind === "raster" && asset.sourceExactTimeUs !== undefined ? { sourceExactTimeUs: asset.sourceExactTimeUs } : {}),
561
- })),
924
+ preparation,
562
925
  costs: { requestBytes: captured.bytes, htmlBytes: new TextEncoder().encode(html).byteLength,
563
926
  decodedTexturePixels, resourceBytes: declaredResources.reduce((sum, resource) => sum + resource.bytes, 0),
564
927
  note: sparkProfile ? "One isolated browser per batch; retained SPZ sources load once, each exact camera sample awaits full-resolution sort. Admission bounds are not measured GPU performance." : "Each batch uses one isolated browser render; scenes and frame-local GPU objects are rebuilt for every selected snapshot. These are admission counts, not measured GPU performance." },
@@ -568,6 +931,14 @@ export function createSpatialOverlayBatch(input: unknown) {
568
931
  return Object.freeze({ authoring: Object.freeze(authoring), metadata: metadata.value as unknown as DeepReadonly<typeof metadataValue>, metadataSha256: metadata.sha256 });
569
932
  }
570
933
 
934
+ /** Fixed golden-spiral gather taps; computed on the host so generated shaders contain no transcendentals. */
935
+ const DOF_TAPS = Object.freeze(Array.from({ length: 16 }, (_, index) => {
936
+ const angle = index * 2.399_963_229_728_653;
937
+ const radius = Math.sqrt((index + 0.5) / 16);
938
+ return `vec2(${(Math.cos(angle) * radius).toFixed(7)},${(Math.sin(angle) * radius).toFixed(7)})`;
939
+ }).join(","));
940
+ const BLOOM_WEIGHTS = Object.freeze([0.2270270270, 0.1945945946, 0.1216216216, 0.0540540541, 0.0162162162].map(weight => weight.toFixed(10)).join(","));
941
+
571
942
  function spatialDocument(payload: string): string {
572
943
  return `<!doctype html>
573
944
  <html><head><meta charset="utf-8"><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent}canvas{display:block;width:100%;height:100%}</style>
@@ -579,20 +950,43 @@ const canvas=document.querySelector("canvas");
579
950
  const renderer=new THREE.WebGLRenderer({canvas,alpha:true,antialias:false,premultipliedAlpha:true,preserveDrawingBuffer:true});
580
951
  renderer.setPixelRatio(1);renderer.setSize(SlopcameraOverlay.width,SlopcameraOverlay.height,false);
581
952
  renderer.setClearColor(0x000000,0);renderer.outputColorSpace=THREE.SRGBColorSpace;
582
- renderer.toneMapping=THREE.NoToneMapping;renderer.autoClear=false;
583
- let beautyTarget=null,outputScene=null,outputCamera=null,outputGeometry=null,outputMaterial=null;
584
- if(input.mode.kind==="beauty"){
585
- if(!renderer.extensions.has("EXT_color_buffer_float"))throw new Error("Spatial SDR beauty requires renderable half-float linear color for correct alpha compositing.");
586
- beautyTarget=new THREE.WebGLRenderTarget(SlopcameraOverlay.width,SlopcameraOverlay.height,{type:THREE.HalfFloatType,format:THREE.RGBAFormat,colorSpace:THREE.LinearSRGBColorSpace,minFilter:THREE.NearestFilter,magFilter:THREE.NearestFilter,depthBuffer:true,stencilBuffer:false});
953
+ renderer.toneMapping=THREE.NoToneMapping;renderer.autoClear=false;renderer.shadowMap.enabled=input.mode.kind==="beauty";renderer.shadowMap.type=THREE.PCFSoftShadowMap;
954
+ ${spatialShadowRuntimeSource()}
955
+ const mode=input.mode;const postSteps=input.effects?input.effects.steps:[];
956
+ const isMotion=mode.kind==="motion";
957
+ const needsVelocity=isMotion||postSteps.some(step=>step.kind==="motion-blur");
958
+ const needsDepth=postSteps.some(step=>step.kind==="depth-of-field");
959
+ const needsBright=postSteps.some(step=>step.kind==="bloom"||step.kind==="flare");
960
+ const hasPost=postSteps.length>0;
961
+ let beautyTarget=null,velocityTarget=null,postA=null,postB=null,brightA=null,brightB=null,outputScene=null,outputCamera=null,outputGeometry=null,outputMaterial=null,postScene=null,postMesh=null,postOutputMaterial=null;
962
+ const resolution=new THREE.Vector2(SlopcameraOverlay.width,SlopcameraOverlay.height);
963
+ const postVertex="precision highp float;attribute vec3 position;attribute vec2 uv;varying vec2 sampleUv;void main(){sampleUv=uv;gl_Position=vec4(position.xy,0.0,1.0);}";
964
+ const toSrgbFn="vec3 toSrgb(vec3 v){return mix(12.92*v,1.055*pow(max(v,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),v));}";
965
+ const postHeader="precision highp float;uniform sampler2D source;uniform vec2 resolution;varying vec2 sampleUv;";
966
+ const makePostTarget=(w,h,depth)=>new THREE.WebGLRenderTarget(w,h,{type:THREE.HalfFloatType,format:THREE.RGBAFormat,colorSpace:THREE.LinearSRGBColorSpace,minFilter:THREE.NearestFilter,magFilter:THREE.NearestFilter,depthBuffer:depth,stencilBuffer:false});
967
+ if(mode.kind==="beauty"||isMotion){
968
+ if(!renderer.extensions.has("EXT_color_buffer_float"))throw new Error("Spatial floating-point passes require renderable half-float color.");
969
+ if(mode.kind==="beauty"){
970
+ beautyTarget=makePostTarget(SlopcameraOverlay.width,SlopcameraOverlay.height,true);
971
+ if(needsDepth){beautyTarget.depthTexture=new THREE.DepthTexture(SlopcameraOverlay.width,SlopcameraOverlay.height);beautyTarget.depthTexture.type=THREE.UnsignedIntType;}
972
+ }
973
+ if(needsVelocity)velocityTarget=makePostTarget(SlopcameraOverlay.width,SlopcameraOverlay.height,true);
974
+ if(hasPost){postA=makePostTarget(SlopcameraOverlay.width,SlopcameraOverlay.height,false);postB=makePostTarget(SlopcameraOverlay.width,SlopcameraOverlay.height,false);}
975
+ if(needsBright){const hw=Math.max(1,SlopcameraOverlay.width>>1),hh=Math.max(1,SlopcameraOverlay.height>>1);brightA=makePostTarget(hw,hh,false);brightB=makePostTarget(hw,hh,false);}
587
976
  outputGeometry=new THREE.PlaneGeometry(2,2);
588
977
  outputMaterial=new THREE.RawShaderMaterial({depthTest:false,depthWrite:false,blending:THREE.NoBlending,transparent:false,toneMapped:false,dithering:false,
589
- uniforms:{linearPremultiplied:{value:beautyTarget.texture}},
590
- vertexShader:"precision highp float;attribute vec3 position;attribute vec2 uv;varying vec2 sampleUv;void main(){sampleUv=uv;gl_Position=vec4(position.xy,0.0,1.0);}",
591
- fragmentShader:"precision highp float;uniform sampler2D linearPremultiplied;varying vec2 sampleUv;vec3 toSrgb(vec3 v){return mix(12.92*v,1.055*pow(max(v,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),v));}void main(){vec4 c=texture2D(linearPremultiplied,sampleUv);if(c.a<=0.0){gl_FragColor=vec4(0.0);return;}gl_FragColor=vec4(toSrgb(c.rgb/c.a)*c.a,c.a);}",
978
+ uniforms:{linearPremultiplied:{value:beautyTarget?beautyTarget.texture:null}},
979
+ vertexShader:postVertex,
980
+ fragmentShader:"precision highp float;uniform sampler2D linearPremultiplied;varying vec2 sampleUv;"+toSrgbFn+"void main(){vec4 c=texture2D(linearPremultiplied,sampleUv);if(c.a<=0.0){gl_FragColor=vec4(0.0);return;}gl_FragColor=vec4(toSrgb(c.rgb/c.a)*c.a,c.a);}",
981
+ });
982
+ postOutputMaterial=new THREE.RawShaderMaterial({depthTest:false,depthWrite:false,blending:THREE.NoBlending,transparent:false,toneMapped:false,dithering:false,
983
+ uniforms:{source:{value:null},resolution:{value:resolution}},vertexShader:postVertex,
984
+ fragmentShader:postHeader+toSrgbFn+"void main(){vec4 c=texture2D(source,sampleUv);gl_FragColor=vec4(toSrgb(max(c.rgb,vec3(0.0)))*c.a,c.a);}",
592
985
  });
593
986
  outputScene=new THREE.Scene();const outputMesh=new THREE.Mesh(outputGeometry,outputMaterial);outputMesh.frustumCulled=false;outputScene.add(outputMesh);outputCamera=new THREE.Camera();
987
+ postScene=new THREE.Scene();postMesh=new THREE.Mesh(outputGeometry);postMesh.frustumCulled=false;postScene.add(postMesh);
594
988
  }
595
- const textures=new Map();let disposed=false;let contextFailure=null;
989
+ const textures=new Map();const particleBuffers=new Map();let disposed=false;let contextFailure=null;
596
990
  const loseContext=event=>{event.preventDefault();contextFailure=new Error("Spatial WebGL context was lost.");};
597
991
  canvas.addEventListener("webglcontextlost",loseContext);
598
992
  const initialization=(async()=>{
@@ -612,6 +1006,17 @@ const initialization=(async()=>{
612
1006
  if(disposed)throw new Error("Spatial renderer was disposed during geometry preparation.");
613
1007
  input.geometry[item.key]=primitives;
614
1008
  }
1009
+ for(const item of input.particleBuffers){
1010
+ const response=await fetch(item.url);if(!response.ok)throw new Error("Prepared particle buffer is unavailable.");
1011
+ const bytes=await response.arrayBuffer();
1012
+ if(bytes.byteLength!==item.resource.bytes||bytes.byteLength!==item.instanceCount*item.strideBytes)throw new Error("Prepared particle buffer length changed.");
1013
+ const hash=new Uint8Array(await crypto.subtle.digest("SHA-256",bytes));
1014
+ const sha256=Array.from(hash,value=>value.toString(16).padStart(2,"0")).join("");
1015
+ if(sha256!==item.resource.sha256)throw new Error("Prepared particle buffer digest changed.");
1016
+ const view=new DataView(bytes);const positions=new Float32Array(item.instanceCount*3);const colors=new Float32Array(item.instanceCount*3);const sizes=new Float32Array(item.instanceCount);const opacity=new Float32Array(item.instanceCount);
1017
+ for(let index=0;index<item.instanceCount;index++){const offset=index*item.strideBytes;positions.set([view.getFloat32(offset,true),view.getFloat32(offset+4,true),view.getFloat32(offset+8,true)],index*3);sizes[index]=view.getFloat32(offset+32,true);opacity[index]=view.getFloat32(offset+36,true);colors.set([view.getFloat32(offset+40,true),view.getFloat32(offset+44,true),view.getFloat32(offset+48,true)],index*3);}
1018
+ particleBuffers.set(item.key,{colors,opacity,positions,sizes});
1019
+ }
615
1020
  for(const item of input.textures){
616
1021
  const texture=await new THREE.TextureLoader().loadAsync(item.url);
617
1022
  if(disposed){texture.dispose();throw new Error("Spatial renderer was disposed during texture preparation.");}
@@ -640,15 +1045,17 @@ float high=floor(code/65536.0);float middle=floor((code-high*65536.0)/256.0);flo
640
1045
  gl_FragColor=vec4(vec3(high,middle,low)/255.0,1.0);}\`;
641
1046
  function makeGeometry(object,track){
642
1047
  const g=object.geometry;let geometry;
643
- if(g.kind==="box")geometry=new THREE.BoxGeometry(...g.size);
1048
+ const displaced=object.material.kind==="pbr"&&object.material.heightMap!==undefined;
1049
+ if(g.kind==="box")geometry=new THREE.BoxGeometry(...g.size,...(displaced?[16,16,16]:[1,1,1]));
644
1050
  else if(g.kind==="sphere")geometry=new THREE.SphereGeometry(g.radius,32,16);
645
- else if(g.kind==="plane")geometry=new THREE.PlaneGeometry(g.width*object.surfaceScale[0],g.height*object.surfaceScale[1]);
646
- else if(g.kind==="cylinder")geometry=new THREE.CylinderGeometry(g.radius,g.radius,g.height,32);
1051
+ else if(g.kind==="plane")geometry=new THREE.PlaneGeometry(g.width*object.surfaceScale[0],g.height*object.surfaceScale[1],displaced?32:1,displaced?32:1);
1052
+ else if(g.kind==="cylinder")geometry=new THREE.CylinderGeometry(g.radius,g.radius,g.height,32,displaced?16:1);
647
1053
  else{const p=input.geometry[g.key][g.primitive];geometry=new THREE.BufferGeometry();
648
1054
  geometry.setAttribute("position",new THREE.Float32BufferAttribute(p.positions,3));
649
1055
  if(p.normals)geometry.setAttribute("normal",new THREE.Float32BufferAttribute(p.normals,3));
650
1056
  if(p.uvs)geometry.setAttribute("uv",new THREE.Float32BufferAttribute(p.uvs,2));
651
1057
  if(p.indices)geometry.setIndex(p.indices);if(!p.normals)geometry.computeVertexNormals();}
1058
+ if(geometry.attributes.uv&&!geometry.attributes.uv1)geometry.setAttribute("uv1",geometry.attributes.uv.clone());
652
1059
  return track(geometry);
653
1060
  }
654
1061
  function makeMaterial(object,frame,track){
@@ -661,7 +1068,7 @@ function makeMaterial(object,frame,track){
661
1068
  const wraps={33071:THREE.ClampToEdgeWrapping,33648:THREE.MirroredRepeatWrapping,10497:THREE.RepeatWrapping};
662
1069
  const filters={9728:THREE.NearestFilter,9729:THREE.LinearFilter,9984:THREE.NearestMipmapNearestFilter,9985:THREE.LinearMipmapNearestFilter,9986:THREE.NearestMipmapLinearFilter,9987:THREE.LinearMipmapLinearFilter};
663
1070
  map.wrapS=wraps[s.wrapS];map.wrapT=wraps[s.wrapT];map.magFilter=filters[s.magFilter??9729];map.minFilter=filters[s.minFilter??9987];map.generateMipmaps=(s.minFilter??9987)>=9984;
664
- }map.needsUpdate=true;
1071
+ }if(options?.uvTransform){map.offset.fromArray(options.uvTransform.offset);map.rotation=options.uvTransform.rotation;map.repeat.fromArray(options.uvTransform.scale);map.wrapS=map.wrapT=THREE.RepeatWrapping;}map.needsUpdate=true;
665
1072
  }
666
1073
  const view=object.placement.kind==="view";
667
1074
  if(input.mode.kind!=="beauty"){
@@ -674,11 +1081,36 @@ function makeMaterial(object,frame,track){
674
1081
  selection:{value:new THREE.Vector3(Math.floor(id/65536)/255,(Math.floor(id/256)%256)/255,(id%256)/255)},
675
1082
  nearClip:{value:frame.camera.near},farClip:{value:frame.camera.far},mode:{value:input.mode.kind==="object-id"?0:view?2:1}}}));
676
1083
  }
677
- if(map){map.repeat.fromArray(object.uvScale);map.offset.fromArray(object.uvOffset);
1084
+ if(map&&object.textureOptions?.uvTransform===undefined){map.repeat.fromArray(object.uvScale);map.offset.fromArray(object.uvOffset);
678
1085
  if(object.flipViewUv){map.repeat.y*=-1;map.offset.y+=object.uvScale[1];}map.needsUpdate=true;}
679
1086
  const transparent=object.alphaMode===undefined?object.alphaCutoff===0&&(m.opacity<1||object.textureAlpha==="straight"):object.alphaMode==="BLEND";
680
1087
  const options={color:m.color,opacity:m.opacity,transparent,map,
681
1088
  side:object.doubleSided?THREE.DoubleSide:THREE.FrontSide,alphaTest:object.alphaCutoff,depthTest:!view,depthWrite:!view&&!transparent,toneMapped:false};
1089
+ const sourceMap=slot=>{const binding=object.sourceTextures?.[slot];if(!binding)return null;const texture=track(textures.get(binding.name).clone());if(binding.uvTransform){texture.offset.fromArray(binding.uvTransform.offset);texture.rotation=binding.uvTransform.rotation;texture.repeat.fromArray(binding.uvTransform.scale);texture.wrapS=texture.wrapT=THREE.RepeatWrapping;}texture.colorSpace=(slot==="emissiveTexture"||slot==="sheenColorTexture")?THREE.SRGBColorSpace:THREE.NoColorSpace;texture.needsUpdate=true;return texture;};
1090
+ if(object.sourcePhysical){const p=object.sourcePhysical;const physical=track(new THREE.MeshPhysicalMaterial({...options,roughness:m.roughness,metalness:m.metalness}));if(object.linearColor)physical.color.setRGB(...object.linearColor,THREE.LinearSRGBColorSpace);
1091
+ physical.normalMap=sourceMap("normalTexture");physical.roughnessMap=physical.metalnessMap=sourceMap("metallicRoughnessTexture");physical.aoMap=sourceMap("occlusionTexture");physical.emissiveMap=sourceMap("emissiveTexture");if(p.emissiveLinear)physical.emissive.setRGB(...p.emissiveLinear,THREE.LinearSRGBColorSpace);physical.emissiveIntensity=p.emissiveStrength??1;
1092
+ if(p.clearcoat){physical.clearcoat=p.clearcoat.factor;physical.clearcoatRoughness=p.clearcoat.roughness;}physical.clearcoatMap=sourceMap("clearcoatTexture");physical.clearcoatRoughnessMap=sourceMap("clearcoatRoughnessTexture");physical.clearcoatNormalMap=sourceMap("clearcoatNormalTexture");
1093
+ physical.transmission=p.transmission??0;physical.transmissionMap=sourceMap("transmissionTexture");if(p.sheen){physical.sheen=1;physical.sheenColor.setRGB(...p.sheen.colorLinear,THREE.LinearSRGBColorSpace);physical.sheenRoughness=p.sheen.roughness;}physical.sheenColorMap=sourceMap("sheenColorTexture");physical.sheenRoughnessMap=sourceMap("sheenRoughnessTexture");if(p.anisotropy){physical.anisotropy=p.anisotropy.strength;physical.anisotropyRotation=p.anisotropy.rotation;}physical.anisotropyMap=sourceMap("anisotropyTexture");if(p.ior!==undefined)physical.ior=p.ior;physical.needsUpdate=true;return physical;}
1094
+ if(m.kind==="pbr"){
1095
+ const pbrMap=ref=>{if(!ref)return null;const name=object.pbrTextures?.[ref.assetId];if(!name)throw new Error("PBR map has no exact prepared texture binding.");
1096
+ const texture=track(textures.get(name).clone());texture.colorSpace=ref.colorSpace==="srgb"?THREE.SRGBColorSpace:THREE.NoColorSpace;
1097
+ if(ref.uvTransform){texture.offset.fromArray(ref.uvTransform.offset);texture.rotation=ref.uvTransform.rotation;texture.repeat.fromArray(ref.uvTransform.scale);texture.center.set(0,0);texture.wrapS=texture.wrapT=THREE.RepeatWrapping;}
1098
+ texture.needsUpdate=true;return texture;};
1099
+ const baseMap=pbrMap(m.baseColorMap);const physical=track(new THREE.MeshPhysicalMaterial({...options,map:baseMap,roughness:m.roughness,metalness:m.metalness}));
1100
+ if(object.linearColor)physical.color.setRGB(...object.linearColor,THREE.LinearSRGBColorSpace);
1101
+ physical.normalMap=pbrMap(m.normalMap);if(physical.normalMap)physical.normalScale.setScalar(m.normalScale??1);
1102
+ if(m.ormMap){const orm=pbrMap(m.ormMap);physical.roughnessMap=orm;physical.metalnessMap=orm;physical.aoMap=orm;}
1103
+ else{physical.roughnessMap=pbrMap(m.roughnessMap);physical.metalnessMap=pbrMap(m.metalnessMap);physical.aoMap=pbrMap(m.aoMap);}
1104
+ physical.aoMapIntensity=m.aoMapIntensity??1;
1105
+ if(m.emissive){physical.emissive=new THREE.Color(m.emissive.color);physical.emissiveIntensity=m.emissive.intensity;physical.emissiveMap=pbrMap(m.emissive.map);}
1106
+ if(m.heightMap){physical.displacementMap=pbrMap(m.heightMap);physical.displacementScale=m.heightScale;}
1107
+ if(m.clearcoat){physical.clearcoat=m.clearcoat.factor;physical.clearcoatRoughness=m.clearcoat.roughness;physical.clearcoatMap=pbrMap(m.clearcoat.map);physical.clearcoatRoughnessMap=pbrMap(m.clearcoat.roughnessMap);physical.clearcoatNormalMap=pbrMap(m.clearcoat.normalMap);if(physical.clearcoatNormalMap)physical.clearcoatNormalScale.setScalar(m.clearcoat.normalScale??1);}
1108
+ if(m.transmission){physical.transmission=m.transmission.factor;physical.transmissionMap=pbrMap(m.transmission.map);}
1109
+ if(m.sheen){physical.sheen=1;physical.sheenColor=new THREE.Color(m.sheen.color);physical.sheenRoughness=m.sheen.roughness;physical.sheenColorMap=pbrMap(m.sheen.colorMap);physical.sheenRoughnessMap=pbrMap(m.sheen.roughnessMap);}
1110
+ if(m.anisotropy){physical.anisotropy=m.anisotropy.strength;physical.anisotropyRotation=m.anisotropy.rotation;physical.anisotropyMap=pbrMap(m.anisotropy.map);}
1111
+ if(m.ior!==undefined)physical.ior=m.ior;physical.needsUpdate=true;
1112
+ return physical;
1113
+ }
682
1114
  const material=track(m.kind==="unlit"?new THREE.MeshBasicMaterial(options):new THREE.MeshStandardMaterial({...options,roughness:m.roughness,metalness:m.metalness}));
683
1115
  if(object.linearColor)material.color.setRGB(...object.linearColor,THREE.LinearSRGBColorSpace);
684
1116
  return material;
@@ -686,23 +1118,145 @@ function makeMaterial(object,frame,track){
686
1118
  function makeCamera(data){
687
1119
  const camera=data.kind==="perspective"?new THREE.PerspectiveCamera():new THREE.OrthographicCamera();camera.matrixAutoUpdate=false;camera.matrix.fromArray(data.cameraToWorld);
688
1120
  camera.projectionMatrix.fromArray(data.projection);camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
1121
+ // Preserve physical metadata for renderer effects without recomputing the authoritative calibrated projection.
1122
+ if(data.lens){camera.filmGauge=data.lens.sensorWidthMm;camera.focus=data.lens.focusDistanceM??10;camera.userData.slopcameraLens=Object.freeze({...data.lens});}
689
1123
  camera.updateMatrixWorld(true);return camera;
690
1124
  }
1125
+ const postMaterial=(fragment,uniforms)=>new THREE.RawShaderMaterial({depthTest:false,depthWrite:false,blending:THREE.NoBlending,transparent:false,toneMapped:false,dithering:false,vertexShader:postVertex,fragmentShader:fragment,uniforms});
1126
+ const velocityVertex="precision highp float;uniform mat4 currentMvp;uniform mat4 previousMvp;uniform vec2 resolution;attribute vec3 position;varying vec2 vPx;void main(){vec4 c=currentMvp*vec4(position,1.0);vec4 p=previousMvp*vec4(position,1.0);vPx=(c.xy/max(c.w,0.000001)-p.xy/max(p.w,0.000001))*resolution*0.5;gl_Position=c;}";
1127
+ const velocityPointVertex="precision highp float;uniform mat4 currentMvp;uniform mat4 previousMvp;uniform mat4 currentMv;uniform mat4 projection;uniform vec2 resolution;uniform float viewportHeight;uniform bool perspective;attribute vec3 position;attribute float particleSize;varying vec2 vPx;void main(){vec4 c=currentMvp*vec4(position,1.0);vec4 p=previousMvp*vec4(position,1.0);vPx=(c.xy/max(c.w,0.000001)-p.xy/max(p.w,0.000001))*resolution*0.5;gl_Position=c;vec4 mv=currentMv*vec4(position,1.0);float scale=0.5*viewportHeight*projection[1][1];gl_PointSize=max(1.0,particleSize*scale/(perspective?max(0.000001,-mv.z):1.0));}";
1128
+ const velocityFragment="precision highp float;varying vec2 vPx;void main(){gl_FragColor=vec4(vPx,0.0,1.0);}";
1129
+ const velocityPointFragment="precision highp float;varying vec2 vPx;void main(){vec2 p=gl_PointCoord*2.0-1.0;float coverage=clamp((1.0-dot(p,p))*8.0,0.0,1.0);if(coverage<=0.0)discard;gl_FragColor=vec4(vPx,0.0,coverage);}";
1130
+ const viewProjection=cam=>new THREE.Matrix4().fromArray(cam.projection).multiply(new THREE.Matrix4().fromArray(cam.cameraToWorld).invert());
1131
+ const buildVelocityScene=(frame,track)=>{
1132
+ const scene=new THREE.Scene();
1133
+ const curVP=viewProjection(frame.camera),prevVP=viewProjection(frame.previousCamera);
1134
+ const curView=new THREE.Matrix4().fromArray(frame.camera.cameraToWorld).invert();
1135
+ const projection=new THREE.Matrix4().fromArray(frame.camera.projection);
1136
+ const add=(geometry,matrix,previous,points)=>{
1137
+ const model=new THREE.Matrix4().fromArray(matrix),previousModel=new THREE.Matrix4().fromArray(previous??matrix);
1138
+ const uniforms={resolution:{value:resolution},currentMvp:{value:curVP.clone().multiply(model)},previousMvp:{value:prevVP.clone().multiply(previousModel)}};
1139
+ const material=points?track(new THREE.RawShaderMaterial({vertexShader:velocityPointVertex,fragmentShader:velocityPointFragment,depthTest:true,depthWrite:true,transparent:false,blending:THREE.NoBlending,toneMapped:false,dithering:false,
1140
+ uniforms:{...uniforms,currentMv:{value:curView.clone().multiply(model)},projection:{value:projection},viewportHeight:{value:SlopcameraOverlay.height},perspective:{value:frame.camera.kind==="perspective"}}}))
1141
+ :track(new THREE.RawShaderMaterial({vertexShader:velocityVertex,fragmentShader:velocityFragment,depthTest:true,depthWrite:true,transparent:false,blending:THREE.NoBlending,toneMapped:false,dithering:false,side:THREE.DoubleSide,uniforms}));
1142
+ const drawable=points?new THREE.Points(geometry,material):new THREE.Mesh(geometry,material);
1143
+ drawable.frustumCulled=false;scene.add(drawable);
1144
+ };
1145
+ for(const object of frame.objects){
1146
+ if(object.kind!=="mesh"||object.placement.kind!=="world")continue;
1147
+ if(isMotion&&object.entityId!==mode.entityId)continue;
1148
+ // Prepared primitives apply their local matrix after the world transform in
1149
+ // the beauty path, while previousMatrix already carries prevWorld*prevPrimitive.
1150
+ const primitive=object.geometry.kind==="prepared"?new THREE.Matrix4().fromArray(input.geometry[object.geometry.key][object.geometry.primitive].matrix):null;
1151
+ const current=primitive===null?object.matrix:new THREE.Matrix4().fromArray(object.matrix).multiply(primitive).toArray();
1152
+ add(makeGeometry(object,track),current,object.previousMatrix??current,false);
1153
+ }
1154
+ for(const item of frame.particles){
1155
+ if(isMotion&&item.entityId!==mode.entityId)continue;
1156
+ const data=particleBuffers.get(item.key);if(!data)continue;
1157
+ const geometry=track(new THREE.BufferGeometry());
1158
+ geometry.setAttribute("position",new THREE.BufferAttribute(data.positions,3));
1159
+ geometry.setAttribute("particleSize",new THREE.BufferAttribute(data.sizes,1));
1160
+ add(geometry,item.matrix,item.previousMatrix,true);
1161
+ }
1162
+ return scene;
1163
+ };
1164
+ const unpremultiplyFragment=postHeader+"void main(){vec4 c=texture2D(source,sampleUv);if(c.a<=0.0){gl_FragColor=vec4(0.0);return;}gl_FragColor=vec4(c.rgb/c.a,c.a);}";
1165
+ const buildStepPasses=(step,frame,index,track,read)=>{
1166
+ const base={source:{value:read},resolution:{value:resolution}};
1167
+ const u=extra=>Object.assign(base,extra);
1168
+ switch(step.kind){
1169
+ case "tone-map":return[{target:"write",material:track(postMaterial(postHeader+"uniform float exposure;uniform float whitePoint;void main(){vec4 c=texture2D(source,sampleUv);vec3 x=max(c.rgb,vec3(0.0))*exp2(exposure);float w2=whitePoint*whitePoint;gl_FragColor=vec4(x*(1.0+x/w2)/(1.0+x),c.a);}",u({exposure:{value:step.exposure},whitePoint:{value:step.whitePoint}})))}];
1170
+ case "vignette":return[{target:"write",material:track(postMaterial(postHeader+"uniform float intensity;uniform float radius;void main(){vec4 c=texture2D(source,sampleUv);float d=length((sampleUv-0.5)*1.41421356);gl_FragColor=vec4(c.rgb*(1.0-intensity*smoothstep(radius,1.0,d)),c.a);}",u({intensity:{value:step.intensity},radius:{value:step.radius}})))}];
1171
+ case "chromatic-aberration":return[{target:"write",material:track(postMaterial(postHeader+"uniform float offsetPixels;uniform float radialFalloff;void main(){vec2 dir=sampleUv-0.5;float len=max(length(dir),0.000001);vec2 off=dir/len*offsetPixels*pow(len*1.41421356,radialFalloff)/resolution;vec4 c=texture2D(source,sampleUv);gl_FragColor=vec4(texture2D(source,sampleUv+off).r,c.g,texture2D(source,sampleUv-off).b,c.a);}",u({offsetPixels:{value:step.offsetPixels},radialFalloff:{value:step.radialFalloff}})))}];
1172
+ case "grain":return[{target:"write",material:track(postMaterial(postHeader+"uniform float intensity;uniform float seed;float grainHash(vec2 p){vec3 q=fract(vec3(p.xyx)*0.1031);q+=dot(q,q.yzx+33.33);return fract((q.x+q.y)*q.z);}void main(){vec4 c=texture2D(source,sampleUv);float n=grainHash(floor(sampleUv*resolution)+vec2(seed,seed*1.618034));gl_FragColor=vec4(max(c.rgb+vec3((n-0.5)*intensity),vec3(0.0)),c.a);}",u({intensity:{value:step.intensity},seed:{value:step.seed+frame.timeUs%7919}})))}];
1173
+ case "depth-of-field":return[{target:"write",material:track(postMaterial(postHeader+"uniform sampler2D depthTex;uniform float nearClip;uniform float farClip;uniform float focusDistance;uniform float aperture;uniform float focalLength;uniform float sensorWidth;uniform bool isPerspective;const vec2 dofTaps[16]=vec2[16](${DOF_TAPS});float viewZ(float d){return isPerspective?nearClip*farClip/max(farClip-d*(farClip-nearClip),0.000001):nearClip+d*(farClip-nearClip);}void main(){vec4 c=texture2D(source,sampleUv);float z=viewZ(texture2D(depthTex,sampleUv).x);float coc=clamp(aperture*focalLength*abs(focusDistance-z)/(focusDistance*max(z,0.0001))*resolution.x/sensorWidth,0.0,32.0);vec3 acc=c.rgb;for(int i=0;i<16;i++){acc+=texture2D(source,sampleUv+dofTaps[i]*coc/resolution).rgb;}gl_FragColor=vec4(acc/17.0,c.a);}",u({depthTex:{value:beautyTarget.depthTexture},nearClip:{value:frame.camera.near},farClip:{value:frame.camera.far},focusDistance:{value:step.focusDistance},aperture:{value:step.aperture},focalLength:{value:step.focalLength},sensorWidth:{value:frame.camera.lens?frame.camera.lens.sensorWidthMm:36},isPerspective:{value:frame.camera.kind==="perspective"}})))}];
1174
+ case "motion-blur":return[{target:"write",material:track(postMaterial(postHeader+"uniform sampler2D velocityTex;uniform int samples;uniform float shutter;void main(){vec4 c=texture2D(source,sampleUv);vec2 v=texture2D(velocityTex,sampleUv).xy*shutter/resolution;vec3 acc=c.rgb;float n=1.0;for(int i=1;i<64;i++){if(i>=samples)break;float t=float(i)/float(samples-1)-0.5;acc+=texture2D(source,sampleUv+v*t).rgb;n+=1.0;}gl_FragColor=vec4(acc/n,c.a);}",u({velocityTex:{value:velocityTarget.texture},samples:{value:step.samples},shutter:{value:step.shutterAngle/360}})))}];
1175
+ case "bloom":{
1176
+ const bright=postHeader+"uniform float threshold;void main(){vec4 c=texture2D(source,sampleUv);gl_FragColor=vec4(max(c.rgb-vec3(threshold),vec3(0.0)),1.0);}";
1177
+ const blur=postHeader+"uniform vec2 texel;uniform float spread;uniform vec2 direction;const float bloomW[5]=float[5](${BLOOM_WEIGHTS});void main(){vec3 acc=texture2D(source,sampleUv).rgb*bloomW[0];for(int i=1;i<5;i++){vec2 o=direction*float(i)*spread*0.125*texel;acc+=(texture2D(source,sampleUv+o).rgb+texture2D(source,sampleUv-o).rgb)*bloomW[i];}gl_FragColor=vec4(acc,1.0);}";
1178
+ const composite=postHeader+"uniform sampler2D bright;uniform float intensity;void main(){vec4 c=texture2D(source,sampleUv);gl_FragColor=vec4(c.rgb+texture2D(bright,sampleUv).rgb*intensity,c.a);}";
1179
+ const halfTexel=new THREE.Vector2(1/brightA.width,1/brightA.height);
1180
+ return[{target:brightA,material:track(postMaterial(bright,u({threshold:{value:step.threshold}})))},
1181
+ {target:brightB,material:track(postMaterial(blur,{source:{value:brightA.texture},resolution:{value:resolution},texel:{value:halfTexel},spread:{value:step.radius},direction:{value:new THREE.Vector2(1,0)}}))},
1182
+ {target:brightA,material:track(postMaterial(blur,{source:{value:brightB.texture},resolution:{value:resolution},texel:{value:halfTexel},spread:{value:step.radius},direction:{value:new THREE.Vector2(0,1)}}))},
1183
+ {target:"write",material:track(postMaterial(composite,u({bright:{value:brightA.texture},intensity:{value:step.intensity}})))}];
1184
+ }
1185
+ case "flare":{
1186
+ const bright=postHeader+"uniform float threshold;void main(){vec4 c=texture2D(source,sampleUv);gl_FragColor=vec4(max(c.rgb-vec3(threshold),vec3(0.0)),1.0);}";
1187
+ const ghosts=postHeader+"uniform sampler2D bright;uniform int ghosts;uniform float haloWidth;uniform float intensity;void main(){vec4 c=texture2D(source,sampleUv);vec3 g=vec3(0.0);float n=0.0;for(int i=0;i<16;i++){if(i>=ghosts)break;float t=float(i)/max(float(ghosts-1),1.0);vec2 guv=vec2(0.5)-(sampleUv-0.5)*mix(0.25,1.5,t);g+=texture2D(bright,guv).rgb*(1.0-t)*(1.0-t);n+=1.0;}vec2 dir=sampleUv-0.5;float len=max(length(dir),0.000001);vec3 h=texture2D(bright,sampleUv-dir/len*haloWidth*0.5).rgb;gl_FragColor=vec4(c.rgb+(g/max(n,1.0)+h)*intensity,c.a);}";
1188
+ return[{target:brightA,material:track(postMaterial(bright,u({threshold:{value:step.threshold}})))},
1189
+ {target:"write",material:track(postMaterial(ghosts,u({bright:{value:brightA.texture},ghosts:{value:step.ghosts},haloWidth:{value:step.haloWidth},intensity:{value:step.intensity}})))}];
1190
+ }
1191
+ case "lut-grade":{
1192
+ const lutTexture=track(textures.get(step.lut.resourceName).clone());
1193
+ lutTexture.flipY=false;lutTexture.colorSpace=THREE.NoColorSpace;lutTexture.needsUpdate=true;
1194
+ const fragment=postHeader+"uniform sampler2D lutTex;uniform float lutSize;uniform float intensity;vec3 lutLookup(vec3 c){float s=lutSize;vec3 cl=clamp(c,0.0,1.0);float b=cl.b*(s-1.0);float b0=floor(b);float b1=min(b0+1.0,s-1.0);vec2 rg=cl.rg*(s-1.0)+0.5;vec3 c0=texture2D(lutTex,vec2((b0*s+rg.x)/(s*s),rg.y/s)).rgb;vec3 c1=texture2D(lutTex,vec2((b1*s+rg.x)/(s*s),rg.y/s)).rgb;return mix(c0,c1,b-b0);}void main(){vec4 c=texture2D(source,sampleUv);gl_FragColor=vec4(mix(c.rgb,lutLookup(c.rgb),intensity),c.a);}";
1195
+ return[{target:"write",material:track(postMaterial(fragment,u({lutTex:{value:lutTexture},lutSize:{value:step.lut.size},intensity:{value:step.intensity}})))}];
1196
+ }
1197
+ default:throw new Error("Unsupported post-process step "+String(step&&step.kind)+".");
1198
+ }
1199
+ };
1200
+ const runPostChain=(frame,index,track)=>{
1201
+ let read=beautyTarget.texture;let written=0;
1202
+ const targets=[postA,postB];
1203
+ const run=(material,target)=>{postMesh.material=material;renderer.setRenderTarget(target);renderer.clear(true,true,true);renderer.render(postScene,outputCamera);};
1204
+ run(track(postMaterial(unpremultiplyFragment,{source:{value:read},resolution:{value:resolution}})),postA);
1205
+ read=postA.texture;
1206
+ for(const step of postSteps)for(const item of buildStepPasses(step,frame,index,track,read)){
1207
+ const target=item.target==="write"?targets[1-written]:item.target;
1208
+ run(item.material,target);
1209
+ if(item.target==="write"){read=targets[1-written].texture;written=1-written;}
1210
+ }
1211
+ return read;
1212
+ };
691
1213
  SlopcameraOverlay.onFrame(({frame:index})=>{
692
1214
  if(disposed)throw new Error("Spatial renderer is disposed.");if(contextFailure)throw contextFailure;
693
1215
  const frame=input.frames[index];if(!frame)throw new Error("Spatial frame index is outside the immutable batch.");
694
1216
  const disposable=[];const track=value=>(disposable.push(value),value);
695
1217
  try{
1218
+ if(isMotion){
1219
+ renderer.setRenderTarget(velocityTarget);renderer.clear(true,true,true);
1220
+ renderer.render(buildVelocityScene(frame,track),outputCamera);
1221
+ postMesh.material=track(postMaterial(postHeader+"uniform float motionScale;uniform float threshold;void main(){vec4 v=texture2D(source,sampleUv);if(v.a<threshold){gl_FragColor=vec4(0.0);return;}vec2 e=clamp(v.xy/motionScale*0.5+0.5,0.0,1.0)*65535.0;vec2 hi=floor(e/256.0);vec2 lo=e-hi*256.0;gl_FragColor=vec4(hi.x/255.0,lo.x/255.0,hi.y/255.0,lo.y/255.0,1.0);}",
1222
+ {source:{value:velocityTarget.texture},resolution:{value:resolution},motionScale:{value:mode.motionScale},threshold:{value:mode.coverage.kind==="opaque"?1:mode.coverage.threshold}}));
1223
+ renderer.setRenderTarget(null);renderer.clear(true,true,true);renderer.render(postScene,outputCamera);
1224
+ if(contextFailure)throw contextFailure;
1225
+ return;
1226
+ }
696
1227
  const world=new THREE.Scene();const view=new THREE.Scene();const camera=makeCamera(frame.camera);
697
1228
  const viewCamera=new THREE.OrthographicCamera(0,SlopcameraOverlay.width,0,SlopcameraOverlay.height,0.01,2000002);viewCamera.position.z=1000001;viewCamera.updateMatrixWorld(true);
1229
+ if(frame.fog&&input.mode.kind==="beauty"){
1230
+ if(frame.fog.kind==="linear")world.fog=new THREE.Fog(frame.fog.color,frame.fog.near,frame.fog.far);
1231
+ else world.fog=new THREE.FogExp2(frame.fog.color,frame.fog.density);
1232
+ }
1233
+ const environment=frame.objects.find(object=>object.kind==="environment");
1234
+ if(environment!==undefined&&input.mode.kind==="beauty"){
1235
+ const equirect=track(textures.get(environment.textureName).clone());
1236
+ equirect.mapping=THREE.EquirectangularReflectionMapping;equirect.needsUpdate=true;
1237
+ const skyRotation=new THREE.Quaternion(...environment.rotation);
1238
+ if(environment.role!=="environment"){world.background=equirect;world.backgroundRotation.setFromQuaternion(skyRotation);world.backgroundIntensity=environment.intensity;}
1239
+ if(environment.role!=="background"){world.environment=equirect;world.environmentRotation.setFromQuaternion(skyRotation);world.environmentIntensity=environment.intensity;}
1240
+ }
1241
+ for(const item of frame.particles){
1242
+ const data=particleBuffers.get(item.key);if(!data)throw new Error("Prepared particle frame buffer is absent.");
1243
+ const geometry=track(new THREE.BufferGeometry());geometry.setAttribute("position",new THREE.BufferAttribute(data.positions,3));geometry.setAttribute("particleColor",new THREE.BufferAttribute(data.colors,3));geometry.setAttribute("particleSize",new THREE.BufferAttribute(data.sizes,1));geometry.setAttribute("particleOpacity",new THREE.BufferAttribute(data.opacity,1));
1244
+ const material=track(new THREE.RawShaderMaterial({transparent:true,depthTest:true,depthWrite:false,blending:THREE.NormalBlending,toneMapped:false,dithering:false,
1245
+ uniforms:{viewportHeight:{value:SlopcameraOverlay.height},perspective:{value:frame.camera.kind==="perspective"}},
1246
+ vertexShader:"precision highp float;uniform mat4 projectionMatrix;uniform mat4 modelViewMatrix;uniform float viewportHeight;uniform bool perspective;attribute vec3 position;attribute vec3 particleColor;attribute float particleSize;attribute float particleOpacity;varying vec3 color;varying float opacity;void main(){vec4 p=modelViewMatrix*vec4(position,1.0);gl_Position=projectionMatrix*p;float scale=0.5*viewportHeight*projectionMatrix[1][1];gl_PointSize=max(1.0,particleSize*scale/(perspective?max(0.000001,-p.z):1.0));color=particleColor;opacity=particleOpacity;}",
1247
+ fragmentShader:"precision highp float;varying vec3 color;varying float opacity;void main(){vec2 p=gl_PointCoord*2.0-1.0;float coverage=clamp((1.0-dot(p,p))*8.0,0.0,1.0);float alpha=opacity*coverage;if(alpha<=0.0)discard;gl_FragColor=vec4(color*alpha,alpha);}"}));
1248
+ const points=new THREE.Points(geometry,material);points.matrixAutoUpdate=false;points.matrix.fromArray(item.matrix);world.add(points);
1249
+ }
698
1250
  for(const object of frame.objects){
699
1251
  if(object.kind==="light"){
700
- const light=object.light==="ambient"?new THREE.AmbientLight(object.color,object.intensity):object.light==="point"?new THREE.PointLight(object.color,object.intensity):new THREE.DirectionalLight(object.color,object.intensity);
701
- light.matrixAutoUpdate=false;light.matrix.fromArray(object.matrix);world.add(light);
702
- if(object.light==="directional"){const target=new THREE.Object3D();target.position.set(0,0,-1).applyMatrix4(light.matrix);world.add(target);light.target=target;}
1252
+ const light=object.light==="ambient"?new THREE.AmbientLight(object.color,object.intensity):object.light==="point"?new THREE.PointLight(object.color,object.intensity):object.light==="spot"?new THREE.SpotLight(object.color,object.intensity,object.spot.distance??0,object.spot.angle,object.spot.penumbra,object.spot.decay??2):new THREE.DirectionalLight(object.color,object.intensity);
1253
+ light.matrixAutoUpdate=false;light.matrix.fromArray(object.matrix);if("castShadow" in light)light.castShadow=object.shadow===true;world.add(light);
1254
+ if(object.light==="directional"||object.light==="spot"){const target=new THREE.Object3D();target.position.set(0,0,-1).applyMatrix4(light.matrix);world.add(target);light.target=target;}
703
1255
  continue;
704
1256
  }
1257
+ if(object.kind==="environment")continue;
705
1258
  const mesh=new THREE.Mesh(makeGeometry(object,track),makeMaterial(object,frame,track));mesh.name=object.entityId;
1259
+ mesh.castShadow=object.castShadow===true;mesh.receiveShadow=object.receiveShadow===true;
706
1260
  mesh.matrixAutoUpdate=false;mesh.matrix.fromArray(object.matrix);
707
1261
  if(object.geometry.kind==="prepared")mesh.matrix.multiply(new THREE.Matrix4().fromArray(input.geometry[object.geometry.key][object.geometry.primitive].matrix));
708
1262
  if(object.placement.kind==="view"){
@@ -710,11 +1264,19 @@ SlopcameraOverlay.onFrame(({frame:index})=>{
710
1264
  mesh.renderOrder=object.placement.order;mesh.frustumCulled=false;view.add(mesh);
711
1265
  }else world.add(mesh);
712
1266
  }
1267
+ if(input.mode.kind==="beauty")configureSpatialShadows(world,renderer.capabilities.maxTextureSize,track);
713
1268
  renderer.setRenderTarget(beautyTarget);renderer.clear(true,true,true);renderer.render(world,camera);renderer.clearDepth();renderer.render(view,viewCamera);
714
- if(beautyTarget){renderer.setRenderTarget(null);renderer.clear(true,true,true);renderer.render(outputScene,outputCamera);}
1269
+ if(beautyTarget){
1270
+ if(velocityTarget){renderer.setRenderTarget(velocityTarget);renderer.clear(true,true,true);renderer.render(buildVelocityScene(frame,track),outputCamera);}
1271
+ renderer.setRenderTarget(null);renderer.clear(true,true,true);
1272
+ if(hasPost){
1273
+ postOutputMaterial.uniforms.source.value=runPostChain(frame,index,track);
1274
+ postMesh.material=postOutputMaterial;renderer.render(postScene,outputCamera);
1275
+ }else renderer.render(outputScene,outputCamera);
1276
+ }
715
1277
  if(contextFailure)throw contextFailure;
716
1278
  }finally{for(const resource of disposable)resource.dispose();}
717
1279
  });
718
- addEventListener("pagehide",()=>{disposed=true;canvas.removeEventListener("webglcontextlost",loseContext);for(const texture of textures.values())texture.dispose();textures.clear();beautyTarget?.dispose();outputGeometry?.dispose();outputMaterial?.dispose();renderer.dispose();renderer.forceContextLoss();});
1280
+ addEventListener("pagehide",()=>{disposed=true;canvas.removeEventListener("webglcontextlost",loseContext);for(const texture of textures.values())texture.dispose();textures.clear();beautyTarget?.dispose();beautyTarget?.depthTexture?.dispose();velocityTarget?.dispose();postA?.dispose();postB?.dispose();brightA?.dispose();brightB?.dispose();outputGeometry?.dispose();outputMaterial?.dispose();postOutputMaterial?.dispose();renderer.dispose();renderer.forceContextLoss();});
719
1281
  </script></body></html>`;
720
1282
  }