@call-me-sensei/toonlab 0.4.21 → 0.4.23

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 (129) hide show
  1. package/NPM-LIBRARY.md +87 -27
  2. package/README.md +11 -10
  3. package/agents/PROMPTS.md +17 -3
  4. package/agents/README.md +13 -3
  5. package/agents/claude/CLAUDE.md +3 -0
  6. package/agents/codex/AGENTS.md +112 -159
  7. package/agents/cursor/toonlab.mdc +7 -3
  8. package/agents/examples/game-foundation.mjs +104 -0
  9. package/agents/references/game-lifecycle.md +57 -0
  10. package/agents/references/runtime-entry-points.md +26 -0
  11. package/agents/references/style-bundles.md +4 -1
  12. package/agents/skills/claude/asset-sourcing/SKILL.md +7 -4
  13. package/agents/skills/claude/environment/SKILL.md +4 -0
  14. package/agents/skills/claude/game-dev/SKILL.md +63 -87
  15. package/agents/skills/claude/lighting/SKILL.md +36 -0
  16. package/agents/skills/claude/post-processing/SKILL.md +8 -3
  17. package/agents/skills/claude/rock-ground-shaders/SKILL.md +10 -15
  18. package/agents/skills/claude/rockgen/SKILL.md +52 -21
  19. package/agents/skills/claude/scene-style-application/SKILL.md +4 -0
  20. package/agents/skills/claude/style-presets/SKILL.md +11 -8
  21. package/agents/skills/claude/toon-shading/SKILL.md +4 -0
  22. package/agents/skills/claude/vegetation-sky/SKILL.md +13 -7
  23. package/agents/skills/claude/visual-verification/SKILL.md +45 -119
  24. package/agents/skills/claude/water/SKILL.md +6 -2
  25. package/agents/skills/codex/asset-sourcing/SKILL.md +7 -4
  26. package/agents/skills/codex/environment/SKILL.md +4 -0
  27. package/agents/skills/codex/game-dev/SKILL.md +63 -87
  28. package/agents/skills/codex/lighting/SKILL.md +36 -0
  29. package/agents/skills/codex/post-processing/SKILL.md +8 -3
  30. package/agents/skills/codex/rock-ground-shaders/SKILL.md +10 -15
  31. package/agents/skills/codex/rockgen/SKILL.md +52 -21
  32. package/agents/skills/codex/scene-style-application/SKILL.md +4 -0
  33. package/agents/skills/codex/style-presets/SKILL.md +11 -8
  34. package/agents/skills/codex/toon-shading/SKILL.md +4 -0
  35. package/agents/skills/codex/vegetation-sky/SKILL.md +13 -7
  36. package/agents/skills/codex/visual-verification/SKILL.md +45 -119
  37. package/agents/skills/codex/water/SKILL.md +6 -2
  38. package/database/apply-one-catalog-seed.mjs +63 -0
  39. package/database/providers.mjs +1 -0
  40. package/database/seeds/catalog/0005_2026-08-c8-first12.sql +1101 -0
  41. package/database/seeds/catalog/0006_2026-09-c8-first100.sql +14245 -0
  42. package/database/seeds/catalog/0007_2026-09-c8-first100-primary-model.sql +14845 -0
  43. package/mcp/lab-management.mjs +11 -8
  44. package/mcp/public-catalog.mjs +0 -16
  45. package/mcp/tree-lab-contract.mjs +2381 -0
  46. package/mcp/vite-plugin.mjs +5 -1
  47. package/package.json +29 -18
  48. package/src/catalog/officialCatalog.js +1 -0
  49. package/src/catalog/officialCatalogAssetRuntime.js +98 -1
  50. package/src/catalog/officialCatalogLod.js +160 -16
  51. package/src/catalog/officialCatalogPlacement.js +52 -12
  52. package/src/catalog/officialCatalogProvider.js +79 -8
  53. package/src/catalog/officialCatalogRockPackage.js +172 -0
  54. package/src/cloud/cloudReprojection.js +40 -7
  55. package/src/cloud/cloudVolume.js +44 -7
  56. package/src/cloud/noise/baseShapeVolume.js +8 -6
  57. package/src/cloud/noise/cirrusMap.js +2 -1
  58. package/src/cloud/noise/curlNoise.js +2 -1
  59. package/src/cloud/noise/erosionVolume.js +2 -1
  60. package/src/cloud/noise/textureCache.js +77 -0
  61. package/src/cloud/noise/weatherMap.js +2 -1
  62. package/src/core/sha256.js +83 -0
  63. package/src/ground-shader/groundShaderMaterial.js +12 -3
  64. package/src/rock-shader/rockMaterial.js +205 -28
  65. package/src/rock-shader/rockShaderRuntime.js +17 -1
  66. package/src/rock-shader/rockShaderSettings.js +16 -0
  67. package/src/rockgen/index.js +9 -0
  68. package/src/rockgen/lod/index.js +1 -0
  69. package/src/rockgen/lod/rockDenseFieldPolicy.js +143 -0
  70. package/src/rockgen/rockDocument.js +899 -35
  71. package/src/rockgen/surface/c7GeologySurface.js +39 -9
  72. package/src/rockgen/surface/naturalRockSurface.js +1948 -0
  73. package/src/shaders-tsl/chunks/projected-water-caustics.js +14 -2
  74. package/src/shaders-tsl/chunks/water-foam.js +6 -6
  75. package/src/shaders-tsl/chunks/water-lighting.js +28 -5
  76. package/src/shaders-tsl/chunks/water-shore-state.js +5 -2
  77. package/src/shaders-tsl/chunks/water-waves.js +10 -5
  78. package/src/shaders-tsl/flower.js +7 -4
  79. package/src/shaders-tsl/grass.js +4 -3
  80. package/src/shaders-tsl/water-shore-state-simulation.js +3 -2
  81. package/src/shaders-tsl/water.js +209 -41
  82. package/src/sky/skySystem.js +30 -7
  83. package/src/toon/settings/perspectiveRemovalSettings.js +1 -1
  84. package/src/toon/toonSettings.js +74 -0
  85. package/src/vegetation/stylizedFlower.js +6 -0
  86. package/src/version.js +1 -1
  87. package/src/water/waterDetailSpectrum.js +91 -0
  88. package/src/water/waterDynamics.js +422 -0
  89. package/src/water/waterFoamParticles.js +196 -0
  90. package/src/water/waterFoamTexture.js +57 -0
  91. package/src/water/waterHydrodynamics.js +209 -0
  92. package/src/water/waterMaterial.js +1 -0
  93. package/src/water/waterRenderExtension.js +107 -0
  94. package/src/water/waterScenePasses.js +14 -6
  95. package/src/water/waterSettings.js +53 -26
  96. package/src/water/waterShoreMaterial.js +38 -14
  97. package/src/water/waterSpectralOcean.js +137 -0
  98. package/src/water/waterStageSettings.js +19 -1
  99. package/src/water/waterSurface.js +62 -18
  100. package/src/water/waterUnderwaterAtmosphere.js +30 -3
  101. package/types/catalog/officialCatalog.d.ts +1 -0
  102. package/types/catalog/officialCatalogPlacement.d.ts +4 -0
  103. package/types/catalog/officialCatalogRockPackage.d.ts +68 -0
  104. package/types/cloud/noise/baseShapeVolume.d.ts +1 -1
  105. package/types/cloud/noise/textureCache.d.ts +12 -0
  106. package/types/core/sha256.d.ts +2 -0
  107. package/types/index.d.ts +30 -0
  108. package/types/rock-shader/rockMaterial.d.ts +3 -0
  109. package/types/rock-shader/rockShaderSettings.d.ts +2 -0
  110. package/types/rockgen/index.d.ts +1 -0
  111. package/types/rockgen/lod/index.d.ts +1 -0
  112. package/types/rockgen/lod/rockDenseFieldPolicy.d.ts +181 -0
  113. package/types/rockgen/rockDocument.d.ts +654 -71
  114. package/types/rockgen/surface/naturalRockSurface.d.ts +100 -0
  115. package/types/shaders-tsl/chunks/projected-water-caustics.d.ts +7 -1
  116. package/types/shaders-tsl/chunks/water-lighting.d.ts +4 -3
  117. package/types/shaders-tsl/water.d.ts +2 -1
  118. package/types/version.d.ts +1 -1
  119. package/types/water/waterDetailSpectrum.d.ts +13 -0
  120. package/types/water/waterDynamics.d.ts +89 -0
  121. package/types/water/waterFoamParticles.d.ts +32 -0
  122. package/types/water/waterFoamTexture.d.ts +1 -0
  123. package/types/water/waterHydrodynamics.d.ts +49 -0
  124. package/types/water/waterRenderExtension.d.ts +16 -0
  125. package/types/water/waterScenePasses.d.ts +1 -1
  126. package/types/water/waterSettings.d.ts +1 -0
  127. package/types/water/waterSpectralOcean.d.ts +17 -0
  128. package/types/water/waterStageSettings.d.ts +1 -1
  129. package/types/water/waterUnderwaterAtmosphere.d.ts +19 -4
@@ -20,7 +20,8 @@ import {
20
20
  resolveCatalogQualityOptions,
21
21
  resolveSceneQualityProfile,
22
22
  } from '../styles/sceneQualityProfiles.js';
23
- import { createCatalogLodRuntime } from './officialCatalogLod.js';
23
+ import { createDenseFieldRockLodRuntime } from '../rockgen/lod/rockDenseFieldPolicy.js';
24
+ import { collectCatalogLodBindings, createCatalogLodRuntime } from './officialCatalogLod.js';
24
25
 
25
26
  function vector3(value, fallback = [0, 0, 0]) {
26
27
  if (Array.isArray(value)) {
@@ -39,6 +40,17 @@ function rotation3(value) {
39
40
  return [0, Number(value) || 0, 0];
40
41
  }
41
42
 
43
+ function mergeSettings(base, patch) {
44
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return base;
45
+ const output = { ...(base ?? {}) };
46
+ for (const [key, value] of Object.entries(patch)) {
47
+ output[key] = value && typeof value === 'object' && !Array.isArray(value)
48
+ ? mergeSettings(output[key], value)
49
+ : value;
50
+ }
51
+ return output;
52
+ }
53
+
42
54
  /**
43
55
  * Validates caller-supplied style textures without reclassifying the asset's
44
56
  * own UV-space maps as triplanar detail maps. applyRockShader reads the full
@@ -54,13 +66,13 @@ function catalogRockTextures(overrides = {}) {
54
66
  };
55
67
  }
56
68
 
57
- function styleAdapterFor(asset, root, { textures: overrides, variation }) {
69
+ function styleAdapterFor(asset, root, { settings: assetSettings, textures: overrides, variation }) {
58
70
  if (asset.domain !== 'natural.rock') return null;
59
71
  const resolved = catalogRockTextures(overrides);
60
72
  const report = { rejectedTextures: resolved.rejected };
61
73
  return Object.freeze({
62
74
  apply(subject, settings) {
63
- const result = applyRockShader(subject, settings, {
75
+ const result = applyRockShader(subject, mergeSettings(settings, assetSettings), {
64
76
  name: `ToonLab · ${asset.label}`,
65
77
  textures: resolved.textures,
66
78
  variation,
@@ -124,6 +136,7 @@ export async function loadOfficialCatalogAsset({
124
136
  styleBundle,
125
137
  targetId = null,
126
138
  textures = {},
139
+ usePublishedRockSurface = true,
127
140
  variation = 0,
128
141
  } = {}) {
129
142
  if (!assetRuntime?.acquireAsset) {
@@ -153,6 +166,7 @@ export async function loadOfficialCatalogAsset({
153
166
  let lod = null;
154
167
  let collisionRegistration = null;
155
168
  let inspectorRegistration = null;
169
+ let publishedRockSurface = null;
156
170
  try {
157
171
  metadata = collisionMetadataFor(asset, collision, collisionAdapter);
158
172
  labelStyleTarget(root, createStyleTargetLabel(asset.domain, {
@@ -160,17 +174,33 @@ export async function loadOfficialCatalogAsset({
160
174
  collision: metadata,
161
175
  targetId: id,
162
176
  }));
163
- adapter = styleAdapterFor(asset, root, { textures, variation });
177
+ if (usePublishedRockSurface
178
+ && asset.domain === 'natural.rock'
179
+ && typeof assetRuntime.getRockShaderInput === 'function') {
180
+ publishedRockSurface = await assetRuntime.getRockShaderInput(asset);
181
+ }
182
+ adapter = styleAdapterFor(asset, root, {
183
+ settings: publishedRockSurface?.settings,
184
+ textures: { ...(publishedRockSurface?.textures ?? {}), ...textures },
185
+ variation,
186
+ });
164
187
  style = await applyStyleBundle(styleBundle, {
165
188
  targets: [createStyleTarget(id, asset.domain, root, { adapter })],
166
189
  });
167
- lod = createCatalogLodRuntime(root, {
168
- distances: resolveCatalogLodDistancesForQuality(
169
- qualityProfile,
170
- asset.lod?.distances,
171
- ),
172
- maxLevel: maxLodLevel,
173
- });
190
+ const authoredLodLevels = [...new Set(
191
+ collectCatalogLodBindings(root).map(({ level }) => level),
192
+ )];
193
+ const usesDenseFieldRockLods = asset.domain === 'natural.rock'
194
+ && authoredLodLevels.includes(4);
195
+ lod = usesDenseFieldRockLods
196
+ ? createDenseFieldRockLodRuntime(root, { maxLevel: maxLodLevel })
197
+ : createCatalogLodRuntime(root, {
198
+ distances: resolveCatalogLodDistancesForQuality(
199
+ qualityProfile,
200
+ asset.lod?.distances,
201
+ ),
202
+ maxLevel: maxLodLevel,
203
+ });
174
204
  if (metadata.kind !== 'none') {
175
205
  collisionRegistration = await registerCollisionTarget({
176
206
  adapter: collisionAdapter,
@@ -191,6 +221,8 @@ export async function loadOfficialCatalogAsset({
191
221
  lod: {
192
222
  availableLevels: lod.availableLevels,
193
223
  enabled: true,
224
+ cullBelowPixels: lod.cullBelowPixels,
225
+ pixelThresholds: lod.pixelThresholds,
194
226
  thresholds: lod.thresholds,
195
227
  },
196
228
  },
@@ -221,6 +253,7 @@ export async function loadOfficialCatalogAsset({
221
253
  lod,
222
254
  normalization,
223
255
  object: root,
256
+ publishedRockSurface,
224
257
  quality: qualityProfile,
225
258
  /** Detail maps the artifact shipped that were too small to be usable. */
226
259
  get rejectedTextures() { return adapter?.report?.rejectedTextures ?? []; },
@@ -238,6 +271,13 @@ export async function loadOfficialCatalogAsset({
238
271
  get released() { return released; },
239
272
  style,
240
273
  targetId: id,
241
- updateLod(options) { return lod.update(options); },
274
+ updateLod(options = {}) {
275
+ if (!lod.pixelThresholds || options.projectedPixels !== undefined) return lod.update(options);
276
+ return lod.update({
277
+ ...options,
278
+ viewportHeight: options.viewportHeight
279
+ ?? (typeof globalThis.innerHeight === 'number' ? globalThis.innerHeight : 1080),
280
+ });
281
+ },
242
282
  });
243
283
  }
@@ -10,8 +10,21 @@ export const OFFICIAL_CATALOG_PROVIDER_TRANSPORTS = Object.freeze([
10
10
  'public-rock',
11
11
  ]);
12
12
 
13
- const ROCK_ID = /^rock-\d{4}$/u;
13
+ const LEGACY_ROCK_ID = /^rock-\d{4}$/u;
14
+ const NATURE_REFERENCE_ROCK_ID = /^rock-c8-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
14
15
  const ROCK_RECIPE_KIND = 'toonlab/rock-recipe';
16
+ const ROCK_GALLERY_RECIPE_SCHEMA = 'toonlab/rock-gallery-recipe';
17
+
18
+ function isRockId(value) {
19
+ return LEGACY_ROCK_ID.test(value) || NATURE_REFERENCE_ROCK_ID.test(value);
20
+ }
21
+
22
+ function isRockRecipe(value) {
23
+ return isRecord(value) && (
24
+ value.kind === ROCK_RECIPE_KIND
25
+ || value.schema === ROCK_GALLERY_RECIPE_SCHEMA
26
+ );
27
+ }
15
28
 
16
29
  function isRecord(value) {
17
30
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -61,6 +74,15 @@ function artifactName(artifact) {
61
74
  return String(artifact?.name ?? artifact?.relative_path ?? artifact?.relativePath ?? '');
62
75
  }
63
76
 
77
+ function artifactKey(value) {
78
+ return String(value ?? '').trim().replace(/^\.\//u, '').toLowerCase();
79
+ }
80
+
81
+ function artifactByName(artifacts, ...names) {
82
+ const wanted = new Set(names.map(artifactKey));
83
+ return artifacts.find((artifact) => wanted.has(artifactKey(artifactName(artifact)))) ?? null;
84
+ }
85
+
64
86
  function isModelArtifact(artifact) {
65
87
  const name = artifactName(artifact).toLowerCase();
66
88
  const contentType = String(artifact?.contentType ?? artifact?.content_type ?? '').toLowerCase();
@@ -107,7 +129,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
107
129
  const source = String(rawAsset.source ?? metadata.source ?? expectedSource ?? '').trim();
108
130
  const kind = String(rawAsset.kind ?? 'model').trim();
109
131
  const catalog = String(metadata.catalog ?? rawAsset.catalog ?? (
110
- recipe?.kind === ROCK_RECIPE_KIND ? 'rocks' : ''
132
+ isRockRecipe(recipe) ? 'rocks' : ''
111
133
  )).trim().toLowerCase();
112
134
  const revision = Number(rawAsset.revision ?? metadata.revision);
113
135
  const recipeHash = String(rawAsset.recipeHash ?? metadata.recipeHash ?? '').trim();
@@ -115,7 +137,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
115
137
  ...(Array.isArray(rawAsset.artifacts) ? rawAsset.artifacts : []),
116
138
  ...(Array.isArray(rawAsset.files) ? rawAsset.files : []),
117
139
  ].filter(isRecord);
118
- const modelArtifact = artifacts.find((entry) => artifactName(entry) === 'rock.glb')
140
+ const modelArtifact = artifactByName(artifacts, 'rock.glb')
119
141
  ?? artifacts.find(isModelArtifact)
120
142
  ?? null;
121
143
  const modelReference = rawAsset.download_url
@@ -137,9 +159,11 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
137
159
  if (kind !== 'model') errors.push(`kind must be model, received ${kind || '(empty)'}`);
138
160
  if (!modelReference) errors.push('a model download URL is required');
139
161
  if (source === 'toonlab-rock') {
140
- if (!ROCK_ID.test(id)) errors.push('ToonLab rock id must match rock-0001');
162
+ if (!isRockId(id)) errors.push('ToonLab rock id must match rock-0001 or rock-c8-geology-subtype');
141
163
  if (catalog !== 'rocks') errors.push(`catalog must be rocks, received ${catalog || '(empty)'}`);
142
- if (recipe?.kind !== ROCK_RECIPE_KIND) errors.push(`recipe kind must be ${ROCK_RECIPE_KIND}`);
164
+ if (!isRockRecipe(recipe)) {
165
+ errors.push(`recipe must use ${ROCK_RECIPE_KIND} or ${ROCK_GALLERY_RECIPE_SCHEMA}`);
166
+ }
143
167
  if (!Number.isInteger(revision) || revision < 1) errors.push('positive integer revision is required');
144
168
  if (!recipeHash) errors.push('recipeHash is required');
145
169
  }
@@ -157,15 +181,61 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
157
181
  sha256: String(artifact.sha256 ?? ''),
158
182
  url: resolveOfficialCatalogUrl(artifactUrl(artifact), baseUrl),
159
183
  }));
184
+ const artifactMap = Object.freeze(Object.fromEntries(normalizedArtifacts
185
+ .filter((artifact) => artifact.name && artifact.url)
186
+ .map((artifact) => [artifact.name, artifact])));
187
+ const artifactUrlByName = (...names) => {
188
+ const artifact = artifactByName(normalizedArtifacts, ...names);
189
+ return artifact?.url ?? null;
190
+ };
160
191
  const release = String(rawAsset.release ?? metadata.release ?? releaseFromUrl(modelUrl) ?? '').trim() || null;
161
192
  const domain = catalog === 'rocks' ? 'natural.rock' : String(metadata.domain ?? '').trim() || null;
162
- const lod = isRecord(recipe?.lod) ? frozenCopy(recipe.lod) : null;
193
+ const recipeLod = isRecord(recipe?.lod) ? recipe.lod : null;
194
+ const metadataLod = isRecord(rawAsset.lod ?? metadata.lod) ? rawAsset.lod ?? metadata.lod : null;
195
+ const lodLevels = Array.isArray(recipe?.output?.lods)
196
+ ? recipe.output.lods.map(String)
197
+ : Array.isArray(metadataLod?.levels)
198
+ ? metadataLod.levels.map(String)
199
+ : [];
200
+ const lodUrls = Object.freeze(Array.from({ length: 5 }, (_, level) => (
201
+ artifactUrlByName(`lod${level}.glb`, `lod/LOD${level}.glb`)
202
+ )));
203
+ const hasLodPackage = lodUrls.some(Boolean) || lodLevels.length > 0;
204
+ const lod = recipeLod || metadataLod || hasLodPackage
205
+ ? Object.freeze({
206
+ ...frozenCopy(recipeLod ?? metadataLod ?? {}),
207
+ cullBelowPixels: Number(
208
+ recipe?.output?.cullBelowPixels
209
+ ?? metadataLod?.cullBelowPixels
210
+ ?? 0,
211
+ ) || 0,
212
+ levels: Object.freeze(lodLevels),
213
+ urls: lodUrls,
214
+ })
215
+ : null;
216
+ const packageFiles = Object.freeze({
217
+ collisionModelUrl: artifactUrlByName('collision.glb'),
218
+ controlModelUrl: artifactUrlByName('control.glb'),
219
+ lodModelUrls: lodUrls,
220
+ manifestUrl: artifactUrlByName('manifest.json'),
221
+ materialConfigUrl: artifactUrlByName('material-config.json'),
222
+ natureProvenanceUrl: artifactUrlByName('nature-provenance.json', 'nature/provenance.json'),
223
+ natureReferenceUrl: artifactUrlByName('nature-reference.jpg', 'nature/reference.jpg'),
224
+ realisticModelUrl: modelUrl,
225
+ realisticPreviewUrl: artifactUrlByName('realistic-preview.png'),
226
+ recipeUrl: artifactUrlByName('recipe.json'),
227
+ retainedHighModelUrl: artifactUrlByName('retained-high.glb', 'high.glb'),
228
+ stylizedModelUrl: artifactUrlByName('rock-stylized.glb'),
229
+ stylizedPreviewUrl: artifactUrlByName('stylized-preview.png'),
230
+ });
231
+ const collisionMetadata = rawAsset.collision ?? metadata.collision;
163
232
 
164
233
  return Object.freeze({
165
234
  artifacts: Object.freeze(normalizedArtifacts),
235
+ artifactMap,
166
236
  catalog,
167
- collision: isRecord(rawAsset.collision ?? metadata.collision)
168
- ? frozenCopy(rawAsset.collision ?? metadata.collision)
237
+ collision: isRecord(collisionMetadata) && typeof collisionMetadata.kind === 'string'
238
+ ? frozenCopy(collisionMetadata)
169
239
  : null,
170
240
  domain,
171
241
  id,
@@ -175,6 +245,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
175
245
  lod,
176
246
  metadata: frozenCopy(metadata),
177
247
  modelUrl,
248
+ packageFiles,
178
249
  provider,
179
250
  provenance: Object.freeze({
180
251
  modelSha256: String(modelArtifact?.sha256 ?? metadata.modelSha256 ?? ''),
@@ -0,0 +1,172 @@
1
+ import {
2
+ LinearFilter,
3
+ LinearMipmapLinearFilter,
4
+ NoColorSpace,
5
+ RepeatWrapping,
6
+ SRGBColorSpace,
7
+ TextureLoader,
8
+ } from 'three';
9
+
10
+ export const OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA = 'toonlab/rock-gallery-recipe';
11
+ export const OFFICIAL_ROCK_MATERIAL_SCHEMA = 'toonlab.pro-rock-material';
12
+
13
+ function isRecord(value) {
14
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
15
+ }
16
+
17
+ function frozenCopy(value) {
18
+ if (Array.isArray(value)) return Object.freeze(value.map(frozenCopy));
19
+ if (!isRecord(value)) return value;
20
+ return Object.freeze(Object.fromEntries(
21
+ Object.entries(value).map(([key, entry]) => [key, frozenCopy(entry)]),
22
+ ));
23
+ }
24
+
25
+ function artifactName(value) {
26
+ return String(value ?? '').trim().replace(/^\.\//u, '').toLowerCase();
27
+ }
28
+
29
+ export function findOfficialCatalogArtifact(asset, ...names) {
30
+ const wanted = new Set(names.map(artifactName));
31
+ return asset?.artifacts?.find((artifact) => wanted.has(artifactName(artifact.name))) ?? null;
32
+ }
33
+
34
+ export function getOfficialCatalogArtifactUrl(asset, ...names) {
35
+ return findOfficialCatalogArtifact(asset, ...names)?.url ?? null;
36
+ }
37
+
38
+ async function fetchJson(fetchImpl, url, label) {
39
+ if (!url) return null;
40
+ if (typeof fetchImpl !== 'function') throw new TypeError(`${label} requires fetch().`);
41
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
42
+ if (!response?.ok) {
43
+ throw new Error(`${label} returned HTTP ${response?.status ?? 'unknown'}: ${url}`);
44
+ }
45
+ try {
46
+ return await response.json();
47
+ } catch (cause) {
48
+ throw new Error(`${label} returned invalid JSON: ${url}`, { cause });
49
+ }
50
+ }
51
+
52
+ function validateRecipe(asset, recipe) {
53
+ if (!recipe) return null;
54
+ if (recipe.schema !== OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA || recipe.version !== 1) {
55
+ throw new Error(`${asset.id} has an unsupported editable rock recipe.`);
56
+ }
57
+ if (recipe.generator?.unit !== 'metre') {
58
+ throw new Error(`${asset.id} editable recipe must use metres.`);
59
+ }
60
+ return frozenCopy(recipe);
61
+ }
62
+
63
+ function validateMaterialConfig(asset, materialConfig) {
64
+ if (!materialConfig) return null;
65
+ if (materialConfig.schema !== OFFICIAL_ROCK_MATERIAL_SCHEMA || materialConfig.version !== 2) {
66
+ throw new Error(`${asset.id} has an unsupported Call Me Sensei material package.`);
67
+ }
68
+ if (materialConfig.assetId !== asset.id || materialConfig.shader?.preset !== 'call_me_sensei') {
69
+ throw new Error(`${asset.id} material package failed identity or preset validation.`);
70
+ }
71
+ if (!isRecord(materialConfig.textures)) {
72
+ throw new Error(`${asset.id} material package has no texture bindings.`);
73
+ }
74
+ return frozenCopy(materialConfig);
75
+ }
76
+
77
+ /**
78
+ * Load the immutable documents associated with a nature-reference rock.
79
+ * Legacy 480 rocks remain valid and simply return null for documents they do
80
+ * not publish.
81
+ */
82
+ export async function loadOfficialCatalogRockPackage(asset, {
83
+ fetchImpl = globalThis.fetch?.bind(globalThis),
84
+ includeManifest = false,
85
+ includeNatureProvenance = false,
86
+ } = {}) {
87
+ if (!asset || asset.domain !== 'natural.rock') {
88
+ throw new TypeError('Official rock package loading requires a normalized natural.rock asset.');
89
+ }
90
+ const files = asset.packageFiles ?? {};
91
+ const [recipeDocument, materialDocument, manifest, natureProvenance] = await Promise.all([
92
+ files.recipeUrl ? fetchJson(fetchImpl, files.recipeUrl, `${asset.id} recipe`) : asset.recipe,
93
+ fetchJson(fetchImpl, files.materialConfigUrl, `${asset.id} material package`),
94
+ includeManifest ? fetchJson(fetchImpl, files.manifestUrl, `${asset.id} manifest`) : null,
95
+ includeNatureProvenance
96
+ ? fetchJson(fetchImpl, files.natureProvenanceUrl, `${asset.id} nature provenance`)
97
+ : null,
98
+ ]);
99
+ const recipe = recipeDocument?.schema === OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA
100
+ ? validateRecipe(asset, recipeDocument)
101
+ : frozenCopy(asset.recipe);
102
+ const materialConfig = validateMaterialConfig(asset, materialDocument);
103
+ return Object.freeze({
104
+ asset,
105
+ files,
106
+ manifest: frozenCopy(manifest),
107
+ materialConfig,
108
+ natureProvenance: frozenCopy(natureProvenance),
109
+ recipe,
110
+ });
111
+ }
112
+
113
+ function configureRockTexture(texture, descriptor, role) {
114
+ texture.name = `ToonLab ${role} · ${descriptor.url}`;
115
+ texture.colorSpace = descriptor.srgb === true ? SRGBColorSpace : NoColorSpace;
116
+ texture.flipY = false;
117
+ texture.wrapS = RepeatWrapping;
118
+ texture.wrapT = RepeatWrapping;
119
+ texture.magFilter = LinearFilter;
120
+ texture.minFilter = LinearMipmapLinearFilter;
121
+ texture.generateMipmaps = true;
122
+ texture.userData.toonlabOfficialRockRole = role;
123
+ texture.userData.toonlabOfficialRockUrl = descriptor.url;
124
+ texture.needsUpdate = true;
125
+ return texture;
126
+ }
127
+
128
+ /** Load the exact texture slots consumed by applyRockShader(). */
129
+ export async function loadOfficialCatalogRockShaderInput(rockPackage, {
130
+ textureLoader = new TextureLoader(),
131
+ } = {}) {
132
+ const config = rockPackage?.materialConfig;
133
+ if (!config) return null;
134
+ const textures = {};
135
+ await Promise.all(Object.entries(config.textures).map(async ([role, descriptor]) => {
136
+ if (!descriptor?.url) throw new Error(`${config.assetId}/${role} has no immutable texture URL.`);
137
+ const texture = await textureLoader.loadAsync(descriptor.url);
138
+ textures[role] = configureRockTexture(texture, descriptor, role);
139
+ }));
140
+ return Object.freeze({
141
+ materialConfig: config,
142
+ settings: frozenCopy(config.shader?.settings ?? {}),
143
+ textures: Object.freeze(textures),
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Portable hand-off for Rock Lab or another editor. Geometry derivatives are
149
+ * deliberately separate: edits target control.glb, while retained high, LODs,
150
+ * collision, and baked/runtime outputs can be regenerated or replaced.
151
+ */
152
+ export function createOfficialCatalogRockEditorDescriptor(asset, rockPackage = null) {
153
+ if (!asset || asset.domain !== 'natural.rock') {
154
+ throw new TypeError('Official rock editor descriptors require a normalized natural.rock asset.');
155
+ }
156
+ const recipe = rockPackage?.recipe ?? asset.recipe;
157
+ const files = asset.packageFiles ?? {};
158
+ if (recipe?.schema !== OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA) return null;
159
+ return Object.freeze({
160
+ assetId: asset.id,
161
+ controlModelUrl: files.controlModelUrl,
162
+ cullBelowPixels: asset.lod?.cullBelowPixels ?? recipe.output?.cullBelowPixels ?? 0,
163
+ editing: frozenCopy(recipe.editing ?? {}),
164
+ lodModelUrls: asset.lod?.urls ?? files.lodModelUrls ?? Object.freeze([]),
165
+ materialConfigUrl: files.materialConfigUrl,
166
+ recipe: frozenCopy(recipe),
167
+ recipeUrl: files.recipeUrl,
168
+ retainedHighModelUrl: files.retainedHighModelUrl,
169
+ collisionModelUrl: files.collisionModelUrl,
170
+ worldUnitMetres: recipe.generator?.unit === 'metre' ? 1 : null,
171
+ });
172
+ }
@@ -97,7 +97,7 @@ function createMrtTarget(width, height, name, distanceName) {
97
97
 
98
98
  const distance = target.textures[1];
99
99
  distance.name = distanceName;
100
- distance.format = THREE.RedFormat;
100
+ distance.format = THREE.RGFormat;
101
101
  distance.type = THREE.HalfFloatType;
102
102
  distance.minFilter = THREE.NearestFilter;
103
103
  distance.magFilter = THREE.NearestFilter;
@@ -157,6 +157,7 @@ export function createCloudReprojection({
157
157
  cloudMaterial = cloudVolume?.material ?? null,
158
158
  cloudUniforms = cloudVolume?.uniforms ?? null,
159
159
  shape,
160
+ wind = null,
160
161
  fade = null,
161
162
  historyDiv = 2,
162
163
  width = 1,
@@ -205,6 +206,7 @@ export function createCloudReprojection({
205
206
  previousCameraPosition: uniform(new THREE.Vector3()),
206
207
  historyValid: uniform(0),
207
208
  cameraStatic: uniform(1),
209
+ windDelta: uniform(new THREE.Vector3()),
208
210
  sourceSize: uniform(new THREE.Vector2(lowWidth, lowHeight)),
209
211
  historySize: uniform(new THREE.Vector2(historyWidth, historyHeight)),
210
212
  freshSlot: uniform(new THREE.Vector2()),
@@ -218,6 +220,7 @@ export function createCloudReprojection({
218
220
  const historyTexture = texture(historyRead.textures[0]);
219
221
  const historyDistanceTexture = texture(historyRead.textures[1]);
220
222
  const temporalHitDistance = property('float', 'toonlabTemporalHitDistance');
223
+ const temporalSceneDistance = property('float', 'toonlabTemporalSceneDistance');
221
224
 
222
225
  const resolveMaterial = new NodeMaterial();
223
226
  resolveMaterial.name = `${name}Resolve`;
@@ -239,6 +242,11 @@ export function createCloudReprojection({
239
242
  const freshSample = marchTexture.sample(srcCenter).toVar();
240
243
  const fallback = marchTexture.sample(uvNode).toVar();
241
244
  const currentHitDistance = marchDistanceTexture.sample(srcCenter).x.toVar();
245
+ const currentSceneDistance = cloudVolume?.sceneDepthNode
246
+ ? select(cloudUniforms.sceneDepthEnabled.greaterThan(0.5),
247
+ min(cloudVolume.sceneDepthNode.sample(uvNode).r, 65000), float(65000)).toVar()
248
+ : float(65000);
249
+ temporalSceneDistance.assign(currentSceneDistance);
242
250
 
243
251
  const neighborhoodMin = vec4(freshSample).toVar();
244
252
  const neighborhoodMax = vec4(freshSample).toVar();
@@ -305,6 +313,7 @@ export function createCloudReprojection({
305
313
  const rayDirection = uniforms.rayBasis.mul(vec3(ndc, 1)).normalize().toVar();
306
314
  const worldHit = uniforms.cameraPosition
307
315
  .add(rayDirection.mul(reprojectionDistance))
316
+ .sub(uniforms.windDelta)
308
317
  .toVar();
309
318
  const previousClip = uniforms.previousViewProjection.mul(vec4(worldHit, 1)).toVar();
310
319
  const previousNdc = previousClip.xy.div(max(abs(previousClip.w), 1e-6)).toVar();
@@ -335,11 +344,17 @@ export function createCloudReprojection({
335
344
  uniforms.historySize,
336
345
  ).toVar();
337
346
  const previousHitDistance = historyDistanceTexture.sample(previousUv).x.toVar();
347
+ const previousSceneDistance = historyDistanceTexture.sample(previousUv).y;
338
348
  history.assign(vec4(max(history.rgb, vec3(0)), clamp(history.a, 0, 1)));
339
349
 
350
+ const depthMatches = abs(expectedPreviousDistance.sub(previousHitDistance))
351
+ .lessThanEqual(float(REJECT_THRESHOLD).mul(max(expectedPreviousDistance, 1)));
340
352
  const historyUsable = inBounds
341
353
  .and(uniforms.historyValid.greaterThan(0.5))
342
354
  .and(carriedSelfDistance.greaterThanEqual(MIN_CARRIED_DISTANCE))
355
+ .and(gateBypass.or(depthMatches))
356
+ .and(abs(previousSceneDistance.sub(currentSceneDistance))
357
+ .lessThanEqual(max(currentSceneDistance.mul(0.02), 10)))
343
358
  .toVar();
344
359
  const clampedHistory = select(
345
360
  uniforms.cameraStatic.greaterThan(0.5),
@@ -350,6 +365,11 @@ export function createCloudReprojection({
350
365
 
351
366
  const currentIsMiss = currentHitDistance
352
367
  .greaterThanEqual(CLOUD_HIT_DISTANCE_MISS_THRESHOLD);
368
+ const opacityConfidence = select(
369
+ uniforms.cameraStatic.greaterThan(0.5),
370
+ float(1),
371
+ saturate(abs(history.a.sub(freshSample.a)).mul(2).oneMinus()),
372
+ );
353
373
  const freshHistoryWeight = select(
354
374
  currentIsMiss,
355
375
  float(FRESH_HISTORY_WEIGHT_NEAR),
@@ -362,7 +382,7 @@ export function createCloudReprojection({
362
382
  currentHitDistance,
363
383
  ),
364
384
  ),
365
- );
385
+ ).mul(opacityConfidence);
366
386
  const freshResolve = select(
367
387
  historyUsable,
368
388
  mix(freshSample, clampedHistory, freshHistoryWeight),
@@ -370,8 +390,6 @@ export function createCloudReprojection({
370
390
  ).toVar();
371
391
  const result = select(isFresh, freshResolve, staleResolve).toVar();
372
392
 
373
- const depthMatches = abs(expectedPreviousDistance.sub(previousHitDistance))
374
- .lessThanEqual(float(REJECT_THRESHOLD).mul(expectedPreviousDistance));
375
393
  const distanceTrusted = historyUsable.and(gateBypass.or(depthMatches));
376
394
  temporalHitDistance.assign(select(
377
395
  isFresh,
@@ -384,11 +402,12 @@ export function createCloudReprojection({
384
402
 
385
403
  resolveMaterial.fragmentNode = mrt({
386
404
  output: resolveColor,
387
- hitDistHistory: vec4(temporalHitDistance, 0, 0, 1),
405
+ hitDistHistory: vec4(temporalHitDistance, temporalSceneDistance, 0, 1),
388
406
  });
389
407
 
390
408
  const quad = new QuadMesh(resolveMaterial);
391
409
  const previousViewProjection = new THREE.Matrix4();
410
+ const previousProjection = new THREE.Matrix4();
392
411
  const previousCameraPosition = new THREE.Vector3();
393
412
  const previousQuaternion = new THREE.Quaternion();
394
413
  const currentPosition = new THREE.Vector3();
@@ -396,6 +415,9 @@ export function createCloudReprojection({
396
415
  const currentForward = new THREE.Vector3();
397
416
  const rightScratch = new THREE.Vector3();
398
417
  const upScratch = new THREE.Vector3();
418
+ const previousWindOffset = new THREE.Vector3();
419
+ const previousEvolutionOffset = new THREE.Vector3();
420
+ const previousForward = new THREE.Vector3();
399
421
  let hasPreviousView = false;
400
422
  let frame = 0;
401
423
  let framesSinceReset = 0;
@@ -438,10 +460,18 @@ export function createCloudReprojection({
438
460
  const cameraMoved = hasPreviousView
439
461
  && (currentPosition.distanceToSquared(previousCameraPosition) >= 1e-8
440
462
  || currentQuaternion.angleTo(previousQuaternion) >= 1e-6);
463
+ const offset = wind?.offset?.value;
464
+ const evolution = wind?.evolutionOffset?.value;
465
+ uniforms.windDelta.value.set(0, 0, 0);
466
+ if (hasPreviousView && offset) uniforms.windDelta.value.subVectors(offset, previousWindOffset);
467
+ const fieldMoved = uniforms.windDelta.value.lengthSq() > 1e-8
468
+ || (hasPreviousView && evolution && evolution.distanceToSquared(previousEvolutionOffset) > 1e-8);
441
469
  const cut = !hasPreviousView
470
+ || !previousProjection.equals(camera.projectionMatrix)
442
471
  || currentPosition.distanceTo(previousCameraPosition) > CLOUD_HISTORY_CUT_DISTANCE
472
+ || uniforms.windDelta.value.length() > CLOUD_HISTORY_CUT_DISTANCE
443
473
  || (hasPreviousView
444
- && currentForward.dot(new THREE.Vector3(0, 0, -1).applyQuaternion(previousQuaternion))
474
+ && currentForward.dot(previousForward.set(0, 0, -1).applyQuaternion(previousQuaternion))
445
475
  < CLOUD_HISTORY_CUT_COSINE);
446
476
  if (cut) reset();
447
477
 
@@ -461,7 +491,7 @@ export function createCloudReprojection({
461
491
 
462
492
  uniforms.cameraPosition.value.copy(currentPosition);
463
493
  uniforms.previousCameraPosition.value.copy(previousCameraPosition);
464
- uniforms.cameraStatic.value = cameraMoved ? 0 : 1;
494
+ uniforms.cameraStatic.value = cameraMoved || fieldMoved ? 0 : 1;
465
495
  uniforms.freshSlot.value.set(cellX, cellY);
466
496
  uniforms.previousViewProjection.value.copy(previousViewProjection);
467
497
 
@@ -501,8 +531,11 @@ export function createCloudReprojection({
501
531
  previousViewProjection
502
532
  .copy(camera.projectionMatrix)
503
533
  .multiply(camera.matrixWorldInverse);
534
+ previousProjection.copy(camera.projectionMatrix);
504
535
  previousCameraPosition.copy(currentPosition);
505
536
  previousQuaternion.copy(currentQuaternion);
537
+ if (offset) previousWindOffset.copy(offset);
538
+ if (evolution) previousEvolutionOffset.copy(evolution);
506
539
  hasPreviousView = true;
507
540
  uniforms.historyValid.value = 1;
508
541
  frame += 1;