@call-me-sensei/toonlab 0.4.19 → 0.4.21

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 (123) hide show
  1. package/NPM-LIBRARY.md +230 -0
  2. package/README.md +78 -36
  3. package/agents/PROMPTS.md +91 -0
  4. package/agents/README.md +9 -14
  5. package/agents/claude/CLAUDE.md +8 -0
  6. package/agents/codex/AGENTS.md +161 -0
  7. package/agents/cursor/toonlab.mdc +37 -0
  8. package/agents/references/mcp-asset-discovery.md +12 -9
  9. package/agents/references/runtime-entry-points.md +5 -4
  10. package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
  11. package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
  12. package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
  13. package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
  14. package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
  15. package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
  16. package/cli/toonlab.mjs +3 -2
  17. package/database/fal-patina.mjs +131 -0
  18. package/database/generation-service.mjs +108 -7
  19. package/database/migrations/0007_creation_revisions.sql +80 -0
  20. package/database/migrations/0008_creation_revision_hardening.sql +98 -0
  21. package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
  22. package/database/migrations/0010_external_catalog_assets.sql +28 -0
  23. package/database/providers.mjs +195 -16
  24. package/database/repository.mjs +518 -70
  25. package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
  26. package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
  27. package/mcp/public-catalog.mjs +4 -52
  28. package/mcp/server.mjs +175 -12
  29. package/mcp/vite-plugin.mjs +78 -2
  30. package/package.json +32 -12
  31. package/scripts/generate-catalog-seed.mjs +34 -7
  32. package/src/asset-policy/catalogLicenses.js +64 -6
  33. package/src/assetlib/loadImported.js +15 -1
  34. package/src/catalog/officialCatalogAssetRuntime.js +13 -2
  35. package/src/catalog/officialCatalogLod.js +34 -1
  36. package/src/catalog/officialCatalogPlacement.js +29 -14
  37. package/src/character/animationRetarget.js +2 -0
  38. package/src/character/characterRig.js +6 -1
  39. package/src/character/characterRuntime.js +175 -7
  40. package/src/environment/environmentMaterialAdapter.js +30 -0
  41. package/src/environment/environmentPresets.js +90 -0
  42. package/src/environment/environmentSunShadowPass.js +42 -0
  43. package/src/environment/toonLabSurfaceLighting.js +108 -10
  44. package/src/environment/urbanPropMaterial.js +104 -2
  45. package/src/lighting/lightingSystem.js +65 -10
  46. package/src/renderer/index.js +7 -0
  47. package/src/renderer/styleComparison.js +985 -0
  48. package/src/rock-shader/index.js +5 -0
  49. package/src/rock-shader/rockGeometryDetail.js +701 -0
  50. package/src/rock-shader/rockMaterial.js +782 -39
  51. package/src/rock-shader/rockRegionRuntime.js +185 -0
  52. package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
  53. package/src/rock-shader/rockShaderRuntime.js +354 -67
  54. package/src/rock-shader/rockShaderSettings.js +246 -16
  55. package/src/rock-shader/rockTangentIntegrity.js +136 -0
  56. package/src/rock-shader/rockTextureIntegrity.js +115 -0
  57. package/src/rockgen/index.js +1 -0
  58. package/src/rockgen/rockDocument.js +27 -3
  59. package/src/rockgen/surface/c7GeologySurface.js +372 -0
  60. package/src/shaders-tsl/water.js +8 -0
  61. package/src/sky/cloudShadow.js +25 -1
  62. package/src/sky/skySystem.js +9 -0
  63. package/src/styles/index.js +23 -0
  64. package/src/styles/neutralStylePresets.js +712 -0
  65. package/src/styles/sceneStyleRuntime.js +51 -4
  66. package/src/styles/styleAdapters.js +13 -2
  67. package/src/styles/styleBundle.js +1 -1
  68. package/src/texgen/evaluateTexture.js +30 -3
  69. package/src/texgen/textureGenerators.js +63 -31
  70. package/src/texgen/textureSettings.js +25 -8
  71. package/src/toon/toonSettings.js +96 -2
  72. package/src/vegetation/branchTree.js +327 -42
  73. package/src/vegetation/index.js +24 -2
  74. package/src/vegetation/scatter.js +372 -1
  75. package/src/vegetation/stylizedTree.js +312 -126
  76. package/src/vegetation/stylizedTreeFoliage.js +229 -7
  77. package/src/vegetation/treeSurfaceTextures.js +328 -0
  78. package/src/version.js +1 -1
  79. package/src/water/waterSettings.js +15 -7
  80. package/types/asset-policy/catalogLicenses.d.ts +2 -0
  81. package/types/asset-policy/index.d.ts +2 -0
  82. package/types/assetlib/loadImported.d.ts +3 -1
  83. package/types/catalog/officialCatalogLod.d.ts +63 -5
  84. package/types/character/characterRig.d.ts +59 -0
  85. package/types/character/characterRuntime.d.ts +1 -0
  86. package/types/environment/environmentMaterialAdapter.d.ts +3 -1
  87. package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
  88. package/types/index.d.ts +89 -7
  89. package/types/lighting/lightingSystem.d.ts +7 -0
  90. package/types/react/index.d.ts +20 -0
  91. package/types/renderer/index.d.ts +1 -0
  92. package/types/renderer/styleComparison.d.ts +409 -0
  93. package/types/rock-shader/index.d.ts +5 -0
  94. package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
  95. package/types/rock-shader/rockMaterial.d.ts +3 -0
  96. package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
  97. package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
  98. package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
  99. package/types/rock-shader/rockShaderSettings.d.ts +19 -5
  100. package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
  101. package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
  102. package/types/rockgen/index.d.ts +1 -0
  103. package/types/rockgen/rockDocument.d.ts +29 -1
  104. package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
  105. package/types/sky/cloudShadow.d.ts +1 -0
  106. package/types/styles/index.d.ts +1 -0
  107. package/types/styles/neutralStylePresets.d.ts +944 -0
  108. package/types/styles/sceneStyleRuntime.d.ts +20 -0
  109. package/types/vegetation/branchTree.d.ts +4 -0
  110. package/types/vegetation/index.d.ts +9 -7
  111. package/types/vegetation/scatter.d.ts +1 -0
  112. package/types/vegetation/stylizedTree.d.ts +1 -2
  113. package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
  114. package/types/version.d.ts +1 -1
  115. package/agents/references/geology-playbook.md +0 -118
  116. package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
  117. package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
  118. package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
  119. package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
  120. package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
  121. package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
  122. package/src/vegetation/legacyTreePresets.js +0 -120
  123. package/types/vegetation/legacyTreePresets.d.ts +0 -22
@@ -8,6 +8,7 @@
8
8
  // material values, distance metrics, and PBR attribute semantics are not identical.
9
9
 
10
10
  import {
11
+ Color,
11
12
  ClampToEdgeWrapping,
12
13
  LinearFilter,
13
14
  LinearMipmapLinearFilter,
@@ -21,6 +22,7 @@ import {
21
22
  TextureLoader,
22
23
  } from 'three';
23
24
  import { MeshPhysicalNodeMaterial } from 'three/webgpu';
25
+ import { mx_fractal_noise_float } from 'three/tsl';
24
26
  import {
25
27
  TBNViewMatrix,
26
28
  abs,
@@ -39,6 +41,7 @@ import {
39
41
  positionWorld,
40
42
  pow,
41
43
  sin,
44
+ smoothstep,
42
45
  step,
43
46
  texture,
44
47
  transformNormalByViewMatrix,
@@ -66,6 +69,10 @@ import {
66
69
  normalizeToonLabRockMaterialLibrarySchema,
67
70
  } from '../environment/toonLabRockMaterialResolver.js';
68
71
  import { installToonLabSurfaceLighting } from '../environment/toonLabSurfaceLighting.js';
72
+ import {
73
+ ROCK_CAVITY_ATTRIBUTE,
74
+ ROCK_EXPOSURE_ATTRIBUTE,
75
+ } from './rockGeometryDetail.js';
69
76
 
70
77
  export const TOONLAB_ROCK_MANIFEST_SCHEMA = 'toonlab.rock-material-library';
71
78
  export const TOONLAB_ROCK_MANIFEST_VERSION = 1;
@@ -153,8 +160,11 @@ export const TOONLAB_ROCK_PROFILE_DEFAULTS = Object.freeze({
153
160
  lighting: Object.freeze({
154
161
  exposure: 1,
155
162
  ambientFloor: 0.04,
163
+ skyColorInfluence: 0.35,
156
164
  skyFillStrength: 1,
157
165
  skyFillTint: Object.freeze([1, 1, 1]),
166
+ shadowFill: 0,
167
+ shadowFillTint: Object.freeze([1, 1, 1]),
158
168
  }),
159
169
  shoreline: Object.freeze({
160
170
  wetBandWidth: 0.8,
@@ -179,11 +189,61 @@ export const TOONLAB_ROCK_PROFILE_DEFAULTS = Object.freeze({
179
189
  colorPower: 1.3,
180
190
  lowColor: Object.freeze([0.2156862915, 0.3254902065, 0.1019607931]),
181
191
  highColor: Object.freeze([0.3333333433, 0.4078431726, 0.2470588386]),
192
+ // --- moss as a material rather than a tint -------------------------
193
+ // All three default to "off" so the shipped look is unchanged: a moss
194
+ // that only mixes color is exactly the previous behaviour.
195
+ //
196
+ // Moss is a matte cushion; stone is not. Leaving roughness alone is why
197
+ // moss reads as a stain *under* the stone's own specular rather than as
198
+ // a plant sitting on it. -1 disables the response.
199
+ roughness: -1,
200
+ // A moss cushion buries the relief it grows over. Flattening the rock's
201
+ // detail normal under the mask is what makes it sit ON the surface;
202
+ // without it the stone's crevices show straight through the moss.
203
+ relief: 0,
204
+ // Break-up of the coverage boundary. The mask is a smooth power curve,
205
+ // so its edge is an airbrushed gradient — the single strongest reason
206
+ // moss reads as paint. A high-frequency sample pushed through the
207
+ // threshold gives the ragged fringe real moss has.
208
+ fringe: 0,
209
+ fringeScale: 0.35,
210
+ // 0..1 blend of the coverage source from the moss albedo's luminance
211
+ // toward a constant, handing patch shape to the geometric moisture field.
212
+ formDriven: 0,
213
+ coverage: -1,
214
+ band: 0.18,
215
+ patchScale: 0.6,
216
+ patchStrength: 0.8,
217
+ patchOctaves: 3,
218
+ patchContrast: 1.6,
219
+ // --- value, form and depth (D19-210 … D19-213) ---------------------
220
+ // Inert defaults: -1 and 0 reproduce the shipped render byte for byte.
221
+ // Remap of the moss map's own luminance range onto the colour ramp. The
222
+ // shipped path uses the raw rgb sample, and the shipped moss albedo lives
223
+ // in a linear luminance band of 0.092-0.279, so the ramp coordinate never
224
+ // clears ~0.09 and `highColor` is unreachable.
225
+ patternFloor: -1,
226
+ patternCeiling: 1,
227
+ // Darkens stone across the wet field, so moss and stone meet at a close
228
+ // value the way they do in life instead of green-on-white.
229
+ damp: 0,
230
+ // Convex, weather-exposed form suppressing moss. Without it the support
231
+ // term can add moss to hollows but cannot take it off a crown.
232
+ exposure: 0,
233
+ // The moss cushion's own raised form, and the occlusion it casts where it
234
+ // meets bare stone.
235
+ cushion: 0,
236
+ contact: 0,
182
237
  }),
183
238
  layers: Object.freeze({
184
239
  maskEnabled: true,
185
240
  sharpness: 0.8,
186
241
  offset: 0.3,
242
+ heightStart: 0.48,
243
+ heightEnd: 0.88,
244
+ cavityStrength: 0,
245
+ exposureStrength: 0,
246
+ semanticMaskStrength: 1,
187
247
  grass: Object.freeze({
188
248
  enabled: true,
189
249
  useGroundShader: false,
@@ -191,6 +251,8 @@ export const TOONLAB_ROCK_PROFILE_DEFAULTS = Object.freeze({
191
251
  tint: Object.freeze([1, 1, 1]),
192
252
  saturation: 1,
193
253
  emission: 0,
254
+ roughness: -1,
255
+ metalness: -1,
194
256
  }),
195
257
  snow: Object.freeze({
196
258
  enabled: false,
@@ -198,6 +260,8 @@ export const TOONLAB_ROCK_PROFILE_DEFAULTS = Object.freeze({
198
260
  tint: Object.freeze([0, 0, 0]),
199
261
  saturation: 1,
200
262
  emission: 0,
263
+ roughness: -1,
264
+ metalness: -1,
201
265
  }),
202
266
  sand: Object.freeze({
203
267
  enabled: true,
@@ -206,6 +270,8 @@ export const TOONLAB_ROCK_PROFILE_DEFAULTS = Object.freeze({
206
270
  tint: Object.freeze([0, 0, 0]),
207
271
  saturation: 0,
208
272
  emission: 0,
273
+ roughness: -1,
274
+ metalness: -1,
209
275
  normalScale: 0,
210
276
  normalStrength: 1,
211
277
  normalRotationDegrees: 30,
@@ -243,6 +309,26 @@ function finite(value, fallback) {
243
309
  return Number.isFinite(result) ? result : fallback;
244
310
  }
245
311
 
312
+ /**
313
+ * Numeric clamp for the JS-side normalizers.
314
+ *
315
+ * NOT `clamp` from `three/tsl`. That name is imported into this module for the
316
+ * shader graph, and calling it on a plain number returns a NODE, not a number —
317
+ * which is silently almost-correct, because a node operand is legal everywhere
318
+ * the graph consumes these values. It is catastrophically wrong everywhere JS
319
+ * does: `node > 0` is false, `node === 0.85` is false, `Number(node)` is NaN,
320
+ * and `JSON.stringify(node)` is not the value.
321
+ *
322
+ * That is not hypothetical. `vertexCavityStrength` was clamped with the TSL
323
+ * function and then gated with `> 0`, so the gate was permanently false and the
324
+ * `rockCavity` attribute never reached the moss mask at all — the support term
325
+ * that the whole patch model is built on was pure slope, which saturates. See
326
+ * D19-214. Four profile scalars were leaking nodes the same way.
327
+ */
328
+ function clampNumber(value, min, max) {
329
+ return Math.min(max, Math.max(min, value));
330
+ }
331
+
246
332
  function bool(value, fallback) {
247
333
  if (typeof value === 'boolean') return value;
248
334
  if (Number.isFinite(Number(value))) return Number(value) !== 0;
@@ -324,7 +410,7 @@ export function normalizeToonLabRockProfile(profile = {}) {
324
410
  finite(base.nearDetailScale, defaults.base.nearDetailScale),
325
411
  0.0001,
326
412
  ),
327
- nearDetailStrength: clamp(
413
+ nearDetailStrength: clampNumber(
328
414
  finite(base.nearDetailStrength, defaults.base.nearDetailStrength),
329
415
  0,
330
416
  1,
@@ -365,34 +451,54 @@ export function normalizeToonLabRockProfile(profile = {}) {
365
451
  },
366
452
  lighting: {
367
453
  exposure: Math.max(finite(lighting.exposure, defaults.lighting.exposure), 0),
368
- ambientFloor: clamp(
454
+ ambientFloor: clampNumber(
369
455
  finite(lighting.ambientFloor, defaults.lighting.ambientFloor),
370
456
  0,
371
457
  1,
372
458
  ),
459
+ skyColorInfluence: clampNumber(
460
+ finite(lighting.skyColorInfluence, defaults.lighting.skyColorInfluence),
461
+ 0,
462
+ 1,
463
+ ),
373
464
  skyFillStrength: Math.max(
374
465
  finite(lighting.skyFillStrength, defaults.lighting.skyFillStrength),
375
466
  0,
376
467
  ),
377
468
  skyFillTint: color3(lighting.skyFillTint, defaults.lighting.skyFillTint),
469
+ shadowFill: clampNumber(
470
+ finite(lighting.shadowFill, defaults.lighting.shadowFill),
471
+ 0,
472
+ 1,
473
+ ),
474
+ shadowFillTint: color3(
475
+ lighting.shadowFillTint,
476
+ defaults.lighting.shadowFillTint,
477
+ ),
378
478
  },
379
479
  shoreline: {
480
+ // Spread first, coerce after — the same shape the moss group uses. The
481
+ // entries below still clamp every field that has a meaningful range; the
482
+ // spread only ensures a field added to the schema later reaches the
483
+ // shader instead of being dropped silently between validation and use.
484
+ ...shoreline,
380
485
  wetBandWidth: Math.max(
381
486
  finite(shoreline.wetBandWidth, defaults.shoreline.wetBandWidth),
382
487
  0,
383
488
  ),
384
- wetBandDarkening: clamp(
489
+ wetBandDarkening: clampNumber(
385
490
  finite(shoreline.wetBandDarkening, defaults.shoreline.wetBandDarkening),
386
491
  0,
387
492
  1,
388
493
  ),
389
- wetRoughness: clamp(
494
+ wetRoughness: clampNumber(
390
495
  finite(shoreline.wetRoughness, defaults.shoreline.wetRoughness),
391
496
  0.02,
392
497
  1,
393
498
  ),
394
499
  },
395
500
  normals: {
501
+ ...normals,
396
502
  distance: finite(normals.distance, defaults.normals.distance),
397
503
  // Do not clamp: shipped Classic Rocks deliberately uses -0.1, producing
398
504
  // Normal Strength 1.1 in the source graph.
@@ -402,6 +508,10 @@ export function normalizeToonLabRockProfile(profile = {}) {
402
508
  normalGreenSign: finite(normals.normalGreenSign, defaults.normals.normalGreenSign),
403
509
  },
404
510
  moss: {
511
+ // Spread first so a field the settings schema gained still reaches the
512
+ // shader; the explicit entries below then coerce and default the ones
513
+ // the graph reads directly. See the note in `rockShaderSettingsToProfile`.
514
+ ...moss,
405
515
  enabled: bool(moss.enabled, defaults.moss.enabled),
406
516
  size: finite(moss.size, defaults.moss.size),
407
517
  sharpness: finite(moss.sharpness, defaults.moss.sharpness),
@@ -410,12 +520,39 @@ export function normalizeToonLabRockProfile(profile = {}) {
410
520
  colorPower: finite(moss.colorPower, defaults.moss.colorPower),
411
521
  lowColor: color3(moss.lowColor, defaults.moss.lowColor),
412
522
  highColor: color3(moss.highColor, defaults.moss.highColor),
523
+ roughness: finite(moss.roughness, defaults.moss.roughness),
524
+ relief: finite(moss.relief, defaults.moss.relief),
525
+ fringe: finite(moss.fringe, defaults.moss.fringe),
526
+ fringeScale: finite(moss.fringeScale, defaults.moss.fringeScale),
527
+ formDriven: finite(moss.formDriven, defaults.moss.formDriven),
528
+ coverage: finite(moss.coverage, defaults.moss.coverage),
529
+ band: finite(moss.band, defaults.moss.band),
530
+ patchScale: finite(moss.patchScale, defaults.moss.patchScale),
531
+ patchStrength: finite(moss.patchStrength, defaults.moss.patchStrength),
532
+ patchOctaves: finite(moss.patchOctaves, defaults.moss.patchOctaves),
533
+ patchContrast: finite(moss.patchContrast, defaults.moss.patchContrast),
534
+ patternFloor: finite(moss.patternFloor, defaults.moss.patternFloor),
535
+ patternCeiling: finite(moss.patternCeiling, defaults.moss.patternCeiling),
536
+ damp: finite(moss.damp, defaults.moss.damp),
537
+ exposure: finite(moss.exposure, defaults.moss.exposure),
538
+ cushion: finite(moss.cushion, defaults.moss.cushion),
539
+ contact: finite(moss.contact, defaults.moss.contact),
413
540
  },
414
541
  layers: {
415
542
  maskEnabled: bool(layers.maskEnabled, defaults.layers.maskEnabled),
416
543
  sharpness: finite(layers.sharpness, defaults.layers.sharpness),
417
544
  offset: finite(layers.offset, defaults.layers.offset),
545
+ heightStart: finite(layers.heightStart, defaults.layers.heightStart),
546
+ heightEnd: finite(layers.heightEnd, defaults.layers.heightEnd),
547
+ cavityStrength: finite(layers.cavityStrength, defaults.layers.cavityStrength),
548
+ exposureStrength: finite(layers.exposureStrength, defaults.layers.exposureStrength),
549
+ semanticMaskStrength: clampNumber(
550
+ finite(layers.semanticMaskStrength, defaults.layers.semanticMaskStrength),
551
+ 0,
552
+ 1,
553
+ ),
418
554
  grass: {
555
+ ...grass,
419
556
  enabled: bool(grass.enabled, defaults.layers.grass.enabled),
420
557
  useGroundShader: bool(
421
558
  grass.useGroundShader,
@@ -425,15 +562,21 @@ export function normalizeToonLabRockProfile(profile = {}) {
425
562
  tint: color3(grass.tint, defaults.layers.grass.tint),
426
563
  saturation: finite(grass.saturation, defaults.layers.grass.saturation),
427
564
  emission: finite(grass.emission, defaults.layers.grass.emission),
565
+ roughness: finite(grass.roughness, defaults.layers.grass.roughness),
566
+ metalness: finite(grass.metalness, defaults.layers.grass.metalness),
428
567
  },
429
568
  snow: {
569
+ ...snow,
430
570
  enabled: bool(snow.enabled, defaults.layers.snow.enabled),
431
571
  scale: finite(snow.scale, defaults.layers.snow.scale),
432
572
  tint: color3(snow.tint, defaults.layers.snow.tint),
433
573
  saturation: finite(snow.saturation, defaults.layers.snow.saturation),
434
574
  emission: finite(snow.emission, defaults.layers.snow.emission),
575
+ roughness: finite(snow.roughness, defaults.layers.snow.roughness),
576
+ metalness: finite(snow.metalness, defaults.layers.snow.metalness),
435
577
  },
436
578
  sand: {
579
+ ...sand,
437
580
  enabled: bool(sand.enabled, defaults.layers.sand.enabled),
438
581
  useGroundShader: bool(
439
582
  sand.useGroundShader,
@@ -443,6 +586,8 @@ export function normalizeToonLabRockProfile(profile = {}) {
443
586
  tint: color3(sand.tint, defaults.layers.sand.tint),
444
587
  saturation: finite(sand.saturation, defaults.layers.sand.saturation),
445
588
  emission: finite(sand.emission, defaults.layers.sand.emission),
589
+ roughness: finite(sand.roughness, defaults.layers.sand.roughness),
590
+ metalness: finite(sand.metalness, defaults.layers.sand.metalness),
446
591
  normalScale: finite(sand.normalScale, defaults.layers.sand.normalScale),
447
592
  normalStrength: finite(sand.normalStrength, defaults.layers.sand.normalStrength),
448
593
  normalRotationDegrees: finite(
@@ -606,6 +751,14 @@ export function toonLabRockProfileFromResolvedMaterial(materialEntry) {
606
751
  normalGreenSign: d.normals.normalGreenSign,
607
752
  },
608
753
  moss: {
754
+ // Spread the defaults first. No TOONLAB renderer property backs the
755
+ // ToonLab-added fields — they are capability, not Unity-shader parity —
756
+ // so they must resolve from the defaults and stay off unless a caller
757
+ // sets them. Naming them one by one meant every new field silently
758
+ // vanished from this path until someone remembered a fifth edit; the
759
+ // spread makes that structurally impossible. The entries below then
760
+ // override exactly the fields a renderer property does back.
761
+ ...d.moss,
609
762
  enabled: resolvedBool(r, '_Moss', d.moss.enabled),
610
763
  size: resolvedNumber(r, '_Moss_Size', d.moss.size),
611
764
  sharpness: resolvedNumber(r, '_Moss_Sharpness', d.moss.sharpness),
@@ -780,7 +933,7 @@ function toonLabTriplanarWeights(normalNode, blend) {
780
933
  return weights.div(max(weights.x.add(weights.y).add(weights.z), 0.000001));
781
934
  }
782
935
 
783
- function toonLabTriplanarColor(map, scale, blend = 1, coordinates = { zSign: 1 }) {
936
+ export function toonLabTriplanarColor(map, scale, blend = 1, coordinates = { zSign: 1 }) {
784
937
  const mapNode = texture(map);
785
938
  const sourcePosition = toonLabSourcePosition(coordinates);
786
939
  const sourceNormal = toonLabSourceGeometryNormal(coordinates);
@@ -845,11 +998,11 @@ function worldNormalToTangent(worldNormal) {
845
998
  return normalize(transpose(TBNViewMatrix).mul(viewNormal));
846
999
  }
847
1000
 
848
- function tangentNormalToView(tangentNormal) {
1001
+ export function tangentNormalToView(tangentNormal) {
849
1002
  return normalize(TBNViewMatrix.mul(tangentNormal));
850
1003
  }
851
1004
 
852
- function toonLabTriplanarNormal(map, scale, blend, greenSign, coordinates) {
1005
+ export function toonLabTriplanarNormal(map, scale, blend, greenSign, coordinates) {
853
1006
  const projected = toonLabSourcePosition(coordinates).div(safeScale(scale));
854
1007
  const geometryNormal = toonLabSourceGeometryNormal(coordinates);
855
1008
  const weights = toonLabTriplanarWeights(geometryNormal, blend);
@@ -903,7 +1056,7 @@ function toonLabSandNormal(map, layer, greenSign, coordinates) {
903
1056
  return toonLabNormalStrength(decoded, layer.normalStrength);
904
1057
  }
905
1058
 
906
- function toonLabTopMask(profile, topMaskTexture) {
1059
+ function toonLabTopMask(profile, topMaskTexture, assetIntegration) {
907
1060
  const denominator = Math.max(1 - profile.layers.offset, 0.000001);
908
1061
  let mask = clamp(
909
1062
  normalWorldGeometry.y
@@ -913,8 +1066,20 @@ function toonLabTopMask(profile, topMaskTexture) {
913
1066
  0,
914
1067
  1,
915
1068
  );
1069
+ if (assetIntegration.semanticMaskAttributes) {
1070
+ const height = smoothstep(
1071
+ float(profile.layers.heightStart),
1072
+ float(Math.max(profile.layers.heightEnd, profile.layers.heightStart + 0.0001)),
1073
+ attribute('rockHeight', 'float'),
1074
+ );
1075
+ const cavity = attribute('rockCavity', 'float').mul(profile.layers.cavityStrength);
1076
+ const exposure = attribute('rockExposure', 'float').mul(profile.layers.exposureStrength);
1077
+ const form = clamp(height.mul(float(1).add(cavity)).mul(float(1).sub(exposure)), 0, 1);
1078
+ mask = mask.mul(form);
1079
+ }
916
1080
  if (profile.layers.maskEnabled && topMaskTexture) {
917
- mask = mask.mul(texture(topMaskTexture).sample(uv()).r);
1081
+ const authored = texture(topMaskTexture).sample(uv()).r;
1082
+ mask = mask.mul(mix(1, authored, profile.layers.semanticMaskStrength));
918
1083
  }
919
1084
  return clamp(mask, 0, 1);
920
1085
  }
@@ -1001,8 +1166,9 @@ function textureKeysForProfile(profile, { includeInactive = false } = {}) {
1001
1166
  }
1002
1167
 
1003
1168
  /**
1004
- * Builds an opaque TSL material from a normalized profile and resolved
1005
- * THREE.Texture objects. This ports the S_Rock graph inputs; final pixels still
1169
+ * Builds a TSL material from a normalized profile and resolved THREE.Texture
1170
+ * objects. Source alpha semantics are retained when an imported material uses
1171
+ * masking or transparency. This ports the S_Rock graph inputs; final pixels still
1006
1172
  * depend on matching ToonLab's deferred BRDF, SSAO, shadows, ambient probe,
1007
1173
  * fog, TAA, and post-processing.
1008
1174
  */
@@ -1015,23 +1181,70 @@ export function createToonRockMaterial({
1015
1181
  const resolvedProfile = normalizeToonLabRockProfile(profile);
1016
1182
  validateTextures(resolvedProfile, textures);
1017
1183
  const resolvedAssetIntegration = {
1018
- sourceAlbedoStrength: clamp(
1184
+ sourceAlbedoStrength: clampNumber(
1019
1185
  finite(assetIntegration?.sourceAlbedoStrength, 0),
1020
1186
  0,
1021
1187
  1,
1022
1188
  ),
1023
- sourceNormalStrength: clamp(
1189
+ sourceNormalStrength: clampNumber(
1024
1190
  finite(assetIntegration?.sourceNormalStrength, 1),
1025
1191
  0,
1026
1192
  2,
1027
1193
  ),
1028
- sourceAoStrength: clamp(
1194
+ sourceAoStrength: clampNumber(
1029
1195
  finite(assetIntegration?.sourceAoStrength, 1),
1030
1196
  0,
1031
1197
  2,
1032
1198
  ),
1199
+ // glTF material factors are already linear working-space values. Keep
1200
+ // them separate from ToonLab inspector colors, which require sRGB decode.
1201
+ sourceColor: color3(assetIntegration?.sourceColor, [1, 1, 1]),
1202
+ sourceEmissive: color3(assetIntegration?.sourceEmissive, [0, 0, 0]),
1203
+ sourceEmissiveIntensity: Math.max(0, finite(assetIntegration?.sourceEmissiveIntensity, 1)),
1204
+ sourceOpacity: clampNumber(finite(assetIntegration?.sourceOpacity, 1), 0, 1),
1205
+ sourceAlphaTest: clampNumber(finite(assetIntegration?.sourceAlphaTest, 0), 0, 1),
1206
+ sourceTransparent: Boolean(assetIntegration?.sourceTransparent),
1207
+ sourceDepthWrite: assetIntegration?.sourceDepthWrite !== false,
1208
+ sourceSide: Number.isInteger(assetIntegration?.sourceSide) ? assetIntegration.sourceSide : null,
1209
+ sourceMetalnessFactor: clampNumber(finite(assetIntegration?.sourceMetalnessFactor, 0), 0, 1),
1210
+ sourceRoughnessFactor: clampNumber(finite(assetIntegration?.sourceRoughnessFactor, 1), 0, 1),
1211
+ sourceSpecularFactor: clampNumber(finite(assetIntegration?.sourceSpecularFactor, 1), 0, 1),
1212
+ primaryNormalStrength: clampNumber(
1213
+ finite(assetIntegration?.primaryNormalStrength, 1),
1214
+ 0,
1215
+ 2,
1216
+ ),
1217
+ primarySpecularStrength: clampNumber(
1218
+ finite(assetIntegration?.primarySpecularStrength, 1),
1219
+ 0,
1220
+ 2,
1221
+ ),
1222
+ semanticMaskAttributes: Boolean(assetIntegration?.semanticMaskAttributes),
1223
+ rockRegionAttributes: Boolean(assetIntegration?.rockRegionAttributes),
1224
+ regionTintStrength: clampNumber(
1225
+ finite(assetIntegration?.regionTintStrength, 0),
1226
+ 0,
1227
+ 1,
1228
+ ),
1229
+ regionBaseTint: color3(assetIntegration?.regionBaseTint, [1, 1, 1]),
1230
+ regionShaftTint: color3(assetIntegration?.regionShaftTint, [1, 1, 1]),
1231
+ regionNeckTint: color3(assetIntegration?.regionNeckTint, [1, 1, 1]),
1232
+ regionCapTint: color3(assetIntegration?.regionCapTint, [1, 1, 1]),
1233
+ regionNeckOverlayStrength: clampNumber(
1234
+ finite(assetIntegration?.regionNeckOverlayStrength, 0),
1235
+ 0,
1236
+ 1,
1237
+ ),
1033
1238
  vertexAoStrength: Math.max(0, finite(assetIntegration?.vertexAoStrength, 0)),
1034
- vertexColorStrength: clamp(
1239
+ // Reads the `rockCavity` channel written by rockGeometryDetail. Gated the
1240
+ // same way as the other optional vertex channels: 0 means the attribute is
1241
+ // not required to exist, so meshes without geometry detail are unaffected.
1242
+ vertexCavityStrength: clampNumber(
1243
+ finite(assetIntegration?.vertexCavityStrength, 0),
1244
+ 0,
1245
+ 4,
1246
+ ),
1247
+ vertexColorStrength: clampNumber(
1035
1248
  finite(assetIntegration?.vertexColorStrength, 0),
1036
1249
  0,
1037
1250
  1,
@@ -1040,7 +1253,7 @@ export function createToonRockMaterial({
1040
1253
 
1041
1254
  const radialDistance = distance(cameraPosition, positionWorld);
1042
1255
  const authoredDistanceScale = resolvedProfile.coordinates.distanceScale;
1043
- const topMask = toonLabTopMask(resolvedProfile, textures.topMask);
1256
+ const topMask = toonLabTopMask(resolvedProfile, textures.topMask, resolvedAssetIntegration);
1044
1257
  const rockProjection = toonLabRockProjection(textures.rock, resolvedProfile);
1045
1258
  let colorNode = toonLabContrast(
1046
1259
  toonLabSaturation(rockProjection, resolvedProfile.base.saturation),
@@ -1055,7 +1268,9 @@ export function createToonRockMaterial({
1055
1268
  // Imported catalog albedo is authored in mesh UV space. Re-projecting it
1056
1269
  // at the package macro scale erased precisely the baked cracks and strata
1057
1270
  // that the controlled blend is meant to retain.
1058
- const sourceProjection = texture(textures.sourceRock).sample(uv()).rgb;
1271
+ const sourceProjection = texture(textures.sourceRock).sample(uv()).rgb.mul(
1272
+ vec3(...resolvedAssetIntegration.sourceColor),
1273
+ );
1059
1274
  colorNode = mix(
1060
1275
  colorNode,
1061
1276
  toonLabContrast(
@@ -1127,6 +1342,33 @@ export function createToonRockMaterial({
1127
1342
  );
1128
1343
  }
1129
1344
 
1345
+ if (
1346
+ resolvedAssetIntegration.rockRegionAttributes
1347
+ && resolvedAssetIntegration.regionTintStrength > 0
1348
+ ) {
1349
+ const regions = attribute('_tl_rock_region', 'vec4');
1350
+ const primaryTint = vec3(...resolvedAssetIntegration.regionBaseTint).mul(regions.x)
1351
+ .add(vec3(...resolvedAssetIntegration.regionShaftTint).mul(regions.y))
1352
+ .add(vec3(...resolvedAssetIntegration.regionCapTint).mul(regions.w));
1353
+ const neckBlend = regions.z.mul(resolvedAssetIntegration.regionNeckOverlayStrength);
1354
+ const geologicalTint = mix(
1355
+ primaryTint,
1356
+ vec3(...resolvedAssetIntegration.regionNeckTint),
1357
+ neckBlend,
1358
+ );
1359
+ colorNode = colorNode.mul(mix(
1360
+ vec3(1),
1361
+ geologicalTint,
1362
+ resolvedAssetIntegration.regionTintStrength,
1363
+ ));
1364
+ }
1365
+
1366
+ // Hoisted so the roughness and normal stages downstream can read the same
1367
+ // coverage the color stage used. Moss that only tints is moss that cannot
1368
+ // sit on the stone.
1369
+ let mossMaskNode = null;
1370
+ let mossDampNode = null;
1371
+ let mossCushionNode = null;
1130
1372
  if (resolvedProfile.moss.enabled) {
1131
1373
  const mossSample = toonLabTriplanarColor(
1132
1374
  textures.moss,
@@ -1134,10 +1376,45 @@ export function createToonRockMaterial({
1134
1376
  1,
1135
1377
  resolvedProfile.coordinates,
1136
1378
  );
1137
- const mossPattern = pow(
1138
- max(mossSample, vec3(0)),
1139
- vec3(resolvedProfile.moss.colorPower),
1140
- );
1379
+ const mossLuminance = dot(max(mossSample, vec3(0)), vec3(0.2126, 0.7152, 0.0722));
1380
+ // --- colour ramp ----------------------------------------------------
1381
+ //
1382
+ // The shipped path feeds the raw rgb sample straight into the palette
1383
+ // `mix()`. That silently assumes the moss map's channels span 0..1, and the
1384
+ // shipped map does not come close: its linear luminance occupies
1385
+ // 0.092-0.279 with 99.3% of texels inside one decile, and its channel means
1386
+ // are r 0.079 / g 0.147 / b 0.041. Two things follow, and together they are
1387
+ // most of why moss rendered as near-black-green camouflage on near-white
1388
+ // stone (D19-210):
1389
+ //
1390
+ // * the ramp coordinate never clears ~0.09, so moss resolves to
1391
+ // `lowColor` — the darkest working stop — essentially everywhere, and
1392
+ // `highColor` and the whole upper half of the authored palette are
1393
+ // unreachable. Five carefully chosen stops collapse to one flat dark
1394
+ // green with no internal variation for the eye to read as texture.
1395
+ // * the coordinate is a vec3, so each palette channel is driven by the
1396
+ // matching texture channel. The map's red is half its green, so the
1397
+ // ramp's red end stays pinned while green rises, skewing hue on top of
1398
+ // crushing value. The coverage mask below was already fixed for exactly
1399
+ // this cross-channel bug; the colour stage was not.
1400
+ //
1401
+ // A floor of 0 or more switches to a scalar coordinate remapped from the
1402
+ // map's measured range, which reaches the full palette and fixes the hue
1403
+ // skew at once. -1 keeps the shipped behaviour byte for byte.
1404
+ let mossPattern;
1405
+ if (resolvedProfile.moss.patternFloor >= 0) {
1406
+ const floor = resolvedProfile.moss.patternFloor;
1407
+ const ceiling = Math.max(floor + 1e-4, resolvedProfile.moss.patternCeiling);
1408
+ mossPattern = pow(
1409
+ clamp(mossLuminance.sub(floor).div(ceiling - floor), 0, 1),
1410
+ float(resolvedProfile.moss.colorPower),
1411
+ );
1412
+ } else {
1413
+ mossPattern = pow(
1414
+ max(mossSample, vec3(0)),
1415
+ vec3(resolvedProfile.moss.colorPower),
1416
+ );
1417
+ }
1141
1418
  const mossColor = mix(
1142
1419
  toonLabColorProperty(resolvedProfile.moss.lowColor),
1143
1420
  toonLabColorProperty(resolvedProfile.moss.highColor),
@@ -1152,13 +1429,255 @@ export function createToonRockMaterial({
1152
1429
  );
1153
1430
  // Coverage is scalar. Using RGB moss texels as the interpolation mask
1154
1431
  // cross-mixed color channels and could turn green source moss magenta.
1155
- const mossCoverage = dot(mossSample, vec3(0.2126, 0.7152, 0.0722));
1156
- const mossMask = clamp(
1157
- pow(mossCoverage.mul(resolvedProfile.moss.multiply).mul(slope), 2),
1158
- 0,
1159
- 1,
1160
- );
1432
+ let mossCoverage = dot(mossSample, vec3(0.2126, 0.7152, 0.0722));
1433
+ // The shader has no coverage-mask input: it reuses the moss ALBEDO as its
1434
+ // own mask. That texture is a colour map, so its luminance is
1435
+ // high-frequency, and pushing it through the gain needed to clear its dark
1436
+ // average (D19-032) binarises the grain into pepper — isolated bright
1437
+ // texels scattered over the stone. It reads as lichen spotting, which is
1438
+ // fine for incidental weathering on a distant cliff and wrong wherever
1439
+ // moss is supposed to be a continuous cushion.
1440
+ //
1441
+ // `formDriven` blends the coverage source toward a constant, handing the
1442
+ // patch shape to the smooth geometric moisture field (slope + cavity)
1443
+ // instead. The texture then modulates a continuous cap rather than
1444
+ // defining it, and `fringe` supplies the ragged boundary. 0 is the shipped
1445
+ // behaviour. See D19-193.
1446
+ if (resolvedProfile.moss.formDriven > 0) {
1447
+ mossCoverage = mix(
1448
+ mossCoverage,
1449
+ float(1),
1450
+ float(Math.min(1, resolvedProfile.moss.formDriven)),
1451
+ );
1452
+ }
1453
+ // Moisture, not slope. Slope alone puts moss on upward faces and nowhere
1454
+ // else, which is why a slope-only mask reads as a flat tint painted onto
1455
+ // ledges. Real moss follows where water lingers and light is indirect:
1456
+ // crevices, hollows, and the shaded junctions between slabs. `rockCavity`
1457
+ // carries that concavity per vertex, so it adds moisture on vertical and
1458
+ // shaded surfaces that the slope term scores at zero — and because it is an
1459
+ // interpolated geometric field rather than a threshold on a constant, the
1460
+ // resulting boundary is irregular and follows the form.
1461
+ let moisture = resolvedAssetIntegration.vertexCavityStrength > 0
1462
+ ? clamp(
1463
+ slope.add(
1464
+ attribute(ROCK_CAVITY_ATTRIBUTE, 'float')
1465
+ .mul(resolvedAssetIntegration.vertexCavityStrength),
1466
+ ),
1467
+ 0,
1468
+ 1,
1469
+ )
1470
+ : slope;
1471
+ // ...and the other half of the same fact. `rockCavity` says where water
1472
+ // lingers; nothing said where it never does. Both terms above only ever
1473
+ // ADD, and `slope` saturates hard — at the garden's resolved sharpness 2.19
1474
+ // / offset -0.205 it reaches 1.0 by n.y = 0.36, which is roughly the top
1475
+ // two-thirds of a boulder. Across that whole region the support field is a
1476
+ // constant 1 and carries no information about the form at all, so the
1477
+ // colonisation noise alone decides where moss lands. Noise uncorrelated
1478
+ // with form is exactly what camouflage is, and it is why moss sat as
1479
+ // happily on an exposed sunlit crown as in a shaded hollow (D19-212).
1480
+ //
1481
+ // `rockExposure` is the rectified negative curvature from the same
1482
+ // estimator, so crowns, ridges and shoulders finally score above zero and
1483
+ // can be subtracted back out. Bare crowns are not a side effect here —
1484
+ // they are the single strongest cue that moss grew rather than was printed.
1485
+ if (
1486
+ resolvedProfile.moss.exposure > 0
1487
+ && resolvedAssetIntegration.vertexCavityStrength > 0
1488
+ ) {
1489
+ moisture = clamp(
1490
+ moisture.sub(
1491
+ attribute(ROCK_EXPOSURE_ATTRIBUTE, 'float')
1492
+ .mul(resolvedProfile.moss.exposure),
1493
+ ),
1494
+ 0,
1495
+ 1,
1496
+ );
1497
+ }
1498
+ let mossMask;
1499
+ if (resolvedProfile.moss.coverage >= 0) {
1500
+ // --- coverage-mask path -------------------------------------------
1501
+ //
1502
+ // The legacy expression below squares its product and clamps, which
1503
+ // makes the transition band extremely narrow: coverage crosses from 0 to
1504
+ // 1 over a sliver of the moisture range. Since moisture is dominated by
1505
+ // the upward-slope term, the result is a near-horizontal waterline with
1506
+ // uniform moss above and bare stone below — the stone reads as dip-dyed
1507
+ // to a level rather than colonised.
1508
+ //
1509
+ // Real moss is patchy: dense in hollows and on shaded faces, thinning to
1510
+ // bare crowns and exposed ridges, with an irregular broken edge and
1511
+ // detached islands. That needs a second, independent field — WHERE it
1512
+ // actually took hold — which the shader had no input for. This is it.
1513
+ //
1514
+ // support = where moss CAN grow (slope + rockCavity)
1515
+ // patch = where it DID colonise (fractal noise, world space)
1516
+ // gate = support gated by patch
1517
+ // mask = smoothstep across a WIDE band, not a squared clamp
1518
+ //
1519
+ // Noise is evaluated in world space, so two placements of the same asset
1520
+ // colonise differently without needing a per-asset uniform, and a stone
1521
+ // does not swim when the scene moves it.
1522
+ const patchNoise = mx_fractal_noise_float(
1523
+ positionWorld.div(Math.max(resolvedProfile.moss.patchScale, 0.01)),
1524
+ Math.max(1, Math.trunc(resolvedProfile.moss.patchOctaves)),
1525
+ 2,
1526
+ 0.5,
1527
+ 1,
1528
+ );
1529
+ // fBm returns roughly [-1, 1]; remap, then push contrast so cushions
1530
+ // separate into distinct masses instead of a smooth cloud.
1531
+ const patch = clamp(
1532
+ patchNoise.mul(0.5).add(0.5).sub(0.5)
1533
+ .mul(resolvedProfile.moss.patchContrast)
1534
+ .add(0.5),
1535
+ 0,
1536
+ 1,
1537
+ );
1538
+ const gate = moisture.mul(
1539
+ mix(float(1), patch, float(resolvedProfile.moss.patchStrength)),
1540
+ );
1541
+ // Threshold IS the coverage dial: at coverage 1 moss takes every
1542
+ // supported surface, at 0.15 only the wettest, best-colonised pockets.
1543
+ const threshold = Math.min(0.98, Math.max(0.02,
1544
+ 1 - resolvedProfile.moss.coverage));
1545
+ const band = Math.max(0.001, resolvedProfile.moss.band);
1546
+ mossMask = clamp(
1547
+ smoothstep(float(threshold - band), float(threshold + band), gate),
1548
+ 0,
1549
+ 1,
1550
+ );
1551
+ // Damp stone. Moss and stone read at a close value in life for a physical
1552
+ // reason: moss only grows where the stone stays wet, and wet stone is
1553
+ // markedly darker than dry stone. The shipped shader had no term for it,
1554
+ // so the only levers on the moss/stone value gap were the shared palette
1555
+ // (which belongs to the ground and the props owner too) and the scene's
1556
+ // global grade. This is the correct third lever, and it also leaves a
1557
+ // damp halo just outside each cushion — moss does not begin at a dry
1558
+ // edge. The halo comes free by taking the same gate at a threshold one
1559
+ // band-width lower, so it always tracks the moss it surrounds.
1560
+ if (resolvedProfile.moss.damp > 0) {
1561
+ mossDampNode = clamp(
1562
+ smoothstep(
1563
+ float(threshold - (band * 3)),
1564
+ float(threshold + band),
1565
+ gate,
1566
+ ),
1567
+ 0,
1568
+ 1,
1569
+ );
1570
+ }
1571
+ // The cushion's own form. `relief` flattens the STONE's detail normal
1572
+ // under the moss, which stops crevices showing through, but nothing ever
1573
+ // raised the moss itself — so the patch stayed geometrically identical to
1574
+ // the rock beneath it and read as a decal (D19-213). A cushion is a soft
1575
+ // raised mass, and what makes the eye read "raised" is that its shading
1576
+ // normal tilts away from the surface as the mass swells.
1577
+ //
1578
+ // The patch field is already a height field in all but name: high inside
1579
+ // a cushion, low outside. Its world-space gradient is therefore the slope
1580
+ // of the cushion's surface, and the finite differences below cost three
1581
+ // extra fBm evaluations at the same period the mask already uses, so the
1582
+ // relief lands exactly on the patches rather than as unrelated bumps.
1583
+ if (resolvedProfile.moss.cushion > 0) {
1584
+ const period = Math.max(resolvedProfile.moss.patchScale, 0.01);
1585
+ // TWO octaves, not the mask's five, and a step sized to them. The
1586
+ // cushion's shape is the MASS of the mound, which is the low-frequency
1587
+ // part of the patch field; the high octaves are the fibrous margin the
1588
+ // `fringe` term already owns. Differencing across all five aliases
1589
+ // those fine octaves and returns a noisy gradient that reads as grain
1590
+ // rather than as a swelling — which is why the first attempt at this
1591
+ // was invisible at any strength.
1592
+ const octaves = 2;
1593
+ const step = 0.14;
1594
+ const at = (dx, dy, dz) => mx_fractal_noise_float(
1595
+ positionWorld.div(period).add(vec3(dx, dy, dz)),
1596
+ octaves,
1597
+ 2,
1598
+ 0.5,
1599
+ 1,
1600
+ );
1601
+ const centre = at(0, 0, 0);
1602
+ mossCushionNode = vec3(
1603
+ at(step, 0, 0).sub(centre),
1604
+ at(0, step, 0).sub(centre),
1605
+ at(0, 0, step).sub(centre),
1606
+ ).div(step);
1607
+ }
1608
+ } else {
1609
+ mossMask = clamp(
1610
+ pow(mossCoverage.mul(resolvedProfile.moss.multiply).mul(moisture), 2),
1611
+ 0,
1612
+ 1,
1613
+ );
1614
+ }
1615
+ if (resolvedProfile.moss.fringe > 0) {
1616
+ // The mask above is a smooth curve, so its boundary is an airbrushed
1617
+ // gradient — moss fading politely to nothing. Real moss ends in a ragged
1618
+ // margin that breaks into detached fragments. Pushing the mask through a
1619
+ // high-frequency field breaks the contour without moving the coverage:
1620
+ // values well inside a patch stay saturated because the perturbation is
1621
+ // weighted by `edge`, which is zero except in the transition band.
1622
+ //
1623
+ // THE SOURCE HAD TO CHANGE. This sampled the moss ALBEDO at a smaller
1624
+ // period and centred it on 0.5, which cannot work and never did: that
1625
+ // texture's linear luminance is 0.092-0.279 with 99.3% of its texels
1626
+ // inside a single decile, so the term evaluated to a near-constant -0.75
1627
+ // and the "fringe" was a uniform erosion of the boundary — the contour
1628
+ // moved inward and stayed just as smooth. The centring assumed a balanced
1629
+ // noise map and got a flat dark colour map (D19-211).
1630
+ //
1631
+ // World-space fBm at a fraction of the patch period is an actual noise
1632
+ // field: balanced about zero by construction, independent of whatever
1633
+ // albedo happens to be bound, and coherent across the triplanar seams the
1634
+ // texture sample was not.
1635
+ const fringeNoise = mx_fractal_noise_float(
1636
+ positionWorld.div(Math.max(
1637
+ resolvedProfile.moss.patchScale * resolvedProfile.moss.fringeScale,
1638
+ 0.01,
1639
+ )),
1640
+ 3,
1641
+ 2,
1642
+ 0.5,
1643
+ 1,
1644
+ );
1645
+ // `edge` peaks at 1 in the middle of the transition and vanishes at both
1646
+ // ends, so the fringe chews the boundary and leaves the interior alone.
1647
+ const edge = mossMask.mul(mossMask.oneMinus()).mul(4);
1648
+ mossMask = clamp(
1649
+ mossMask.add(
1650
+ fringeNoise.mul(edge).mul(resolvedProfile.moss.fringe),
1651
+ ),
1652
+ 0,
1653
+ 1,
1654
+ );
1655
+ }
1656
+ mossMaskNode = mossMask;
1657
+ // Damp the STONE before the moss is mixed over it, so the darkening lands
1658
+ // on bare rock in the halo and is simply overwritten inside the patches.
1659
+ if (mossDampNode) {
1660
+ colorNode = colorNode.mul(
1661
+ float(1).sub(mossDampNode.mul(resolvedProfile.moss.damp)),
1662
+ );
1663
+ }
1161
1664
  colorNode = mix(colorNode, mossColor, mossMask);
1665
+ // Contact shading. A raised mass occludes the crease where it meets the
1666
+ // surface it sits on; without that line the eye has no evidence the cushion
1667
+ // has any thickness, which is the other half of why moss read as flat. The
1668
+ // `edge` weighting confines it to the margin, so cushion interiors and open
1669
+ // stone are both untouched.
1670
+ if (resolvedProfile.moss.contact > 0) {
1671
+ // Cubed, not linear. `mask·(1-mask)·4` peaks at 1 but falls off slowly,
1672
+ // and once `fringe` has chewed the boundary the mask is in transition
1673
+ // across a wide, noisy region — so a linear weight smears sooty lace over
1674
+ // half the stone instead of drawing a crease. The cube pulls it back to a
1675
+ // narrow line hugging the actual margin.
1676
+ const margin = pow(mossMask.mul(mossMask.oneMinus()).mul(4), float(3));
1677
+ colorNode = colorNode.mul(
1678
+ float(1).sub(margin.mul(resolvedProfile.moss.contact)),
1679
+ );
1680
+ }
1162
1681
  }
1163
1682
 
1164
1683
  let layerState = {
@@ -1175,6 +1694,22 @@ export function createToonRockMaterial({
1175
1694
  resolvedProfile.coordinates,
1176
1695
  );
1177
1696
  const sceneWaterLevel = uniform(-10000);
1697
+ const sceneSkyColor = uniform(new Color(1, 1, 1));
1698
+ const skyPeak = max(
1699
+ max(sceneSkyColor.r, sceneSkyColor.g),
1700
+ max(sceneSkyColor.b, float(0.001)),
1701
+ );
1702
+ const skyChroma = sceneSkyColor.div(skyPeak);
1703
+ const lowLightProbeBlend = float(1).sub(smoothstep(
1704
+ float(0.45),
1705
+ float(0.8),
1706
+ skyPeak,
1707
+ ));
1708
+ const readabilityTint = mix(
1709
+ vec3(1, 1, 1),
1710
+ skyChroma,
1711
+ resolvedProfile.lighting.skyColorInfluence,
1712
+ );
1178
1713
  const wetBandMask = clamp(
1179
1714
  abs(positionWorld.y.sub(sceneWaterLevel)).div(max(
1180
1715
  resolvedProfile.shoreline.wetBandWidth,
@@ -1188,11 +1723,21 @@ export function createToonRockMaterial({
1188
1723
  );
1189
1724
  layerState = {
1190
1725
  color: layerState.color
1191
- .mul(resolvedProfile.lighting.exposure)
1192
1726
  .mul(wetColorScale),
1193
1727
  emission: layerState.emission
1194
- .mul(resolvedProfile.lighting.exposure)
1195
- .add(layerState.color.mul(resolvedProfile.lighting.ambientFloor))
1728
+ // A small albedo-relative floor keeps deep faces legible, but its color
1729
+ // is transient scene state. Feeding the active sky here means the same
1730
+ // sandstone is cool under a daylight sky and warm at golden hour without
1731
+ // baking either cast into its geological Base Color.
1732
+ .add(mix(
1733
+ layerState.color.mul(readabilityTint),
1734
+ vec3(dot(layerState.color, vec3(0.2126, 0.7152, 0.0722)))
1735
+ .mul(readabilityTint),
1736
+ lowLightProbeBlend.mul(0.7),
1737
+ ).mul(
1738
+ float(resolvedProfile.lighting.ambientFloor)
1739
+ .mul(lowLightProbeBlend.mul(3).add(1)),
1740
+ ))
1196
1741
  .mul(wetColorScale),
1197
1742
  };
1198
1743
  layerState = applyToonLabSubLayer(
@@ -1240,7 +1785,12 @@ export function createToonRockMaterial({
1240
1785
  // glTF metallic-roughness textures store perceptual roughness in G.
1241
1786
  // Blend that authored breakup into the shared rock smoothness instead of
1242
1787
  // retaining a texture reference that the visible shader never consumes.
1243
- const sourceSmoothness = texture(textures.sourceRoughness).sample(uv()).g.oneMinus();
1788
+ const sourceSmoothness = clamp(
1789
+ texture(textures.sourceRoughness).sample(uv()).g
1790
+ .mul(resolvedAssetIntegration.sourceRoughnessFactor),
1791
+ 0,
1792
+ 1,
1793
+ ).oneMinus();
1244
1794
  rockSmoothness = mix(
1245
1795
  rockSmoothness,
1246
1796
  sourceSmoothness,
@@ -1286,7 +1836,10 @@ export function createToonRockMaterial({
1286
1836
  );
1287
1837
  combinedNormal = toonLabNormalBlend(
1288
1838
  stylizedNormal,
1289
- toonLabNormalStrength(crackNormal, float(1).sub(flatness)),
1839
+ toonLabNormalStrength(
1840
+ crackNormal,
1841
+ float(1).sub(flatness).mul(resolvedAssetIntegration.primaryNormalStrength),
1842
+ ),
1290
1843
  );
1291
1844
  }
1292
1845
  if (
@@ -1309,6 +1862,20 @@ export function createToonRockMaterial({
1309
1862
  );
1310
1863
  }
1311
1864
 
1865
+ // A moss cushion buries the relief underneath it. Flattening the rock's
1866
+ // detail normal toward the surface normal wherever moss is dense is what
1867
+ // separates "moss growing on stone" from "green painted over stone" — with
1868
+ // the stone's crevice normals still showing through, the eye reads one
1869
+ // surface with a colour on it. Applied before the top-layer blend so a
1870
+ // grass/snow layer still wins where it overlaps.
1871
+ if (mossMaskNode && resolvedProfile.moss.relief > 0) {
1872
+ combinedNormal = normalize(mix(
1873
+ combinedNormal,
1874
+ vec3(0, 0, 1),
1875
+ mossMaskNode.mul(resolvedProfile.moss.relief),
1876
+ ));
1877
+ }
1878
+
1312
1879
  const hasTopLayer = resolvedProfile.layers.grass.enabled
1313
1880
  || resolvedProfile.layers.snow.enabled
1314
1881
  || resolvedProfile.layers.sand.enabled;
@@ -1335,12 +1902,92 @@ export function createToonRockMaterial({
1335
1902
  && resolvedProfile.layers.sand.useGroundShader
1336
1903
  );
1337
1904
  let metalnessNode = clamp(float(resolvedProfile.base.metallic), 0, 1);
1905
+ if (
1906
+ resolvedAssetIntegration.sourceAlbedoStrength > 0
1907
+ && textures.sourceMetalness?.isTexture
1908
+ ) {
1909
+ // glTF packs metalness in B and roughness in G.
1910
+ const sourceMetalness = clamp(
1911
+ texture(textures.sourceMetalness).sample(uv()).b
1912
+ .mul(resolvedAssetIntegration.sourceMetalnessFactor),
1913
+ 0,
1914
+ 1,
1915
+ );
1916
+ metalnessNode = mix(
1917
+ metalnessNode,
1918
+ sourceMetalness,
1919
+ resolvedAssetIntegration.sourceAlbedoStrength,
1920
+ );
1921
+ }
1338
1922
  let roughnessNode = mix(
1339
1923
  clamp(finalSmoothness.oneMinus(), 0, 1),
1340
1924
  resolvedProfile.shoreline.wetRoughness,
1341
1925
  wetBandMask,
1342
1926
  );
1343
- let specularIntensityNode = float(1);
1927
+ // Moss is matte and stone is not. Without this the moss patch carries the
1928
+ // stone's specular response and lights like wet rock, which is most of why
1929
+ // it reads as a stain rather than a plant.
1930
+ if (mossMaskNode && resolvedProfile.moss.roughness >= 0) {
1931
+ roughnessNode = mix(
1932
+ roughnessNode,
1933
+ float(Math.min(1, Math.max(0, resolvedProfile.moss.roughness))),
1934
+ mossMaskNode,
1935
+ );
1936
+ }
1937
+ let specularIntensityNode = float(resolvedAssetIntegration.primarySpecularStrength);
1938
+ if (
1939
+ resolvedAssetIntegration.sourceAlbedoStrength > 0
1940
+ && textures.sourceSpecular?.isTexture
1941
+ ) {
1942
+ // KHR_materials_specular stores dielectric intensity in alpha.
1943
+ const sourceSpecular = clamp(
1944
+ texture(textures.sourceSpecular).sample(uv()).a
1945
+ .mul(resolvedAssetIntegration.sourceSpecularFactor),
1946
+ 0,
1947
+ 1,
1948
+ );
1949
+ specularIntensityNode = mix(
1950
+ specularIntensityNode,
1951
+ sourceSpecular,
1952
+ resolvedAssetIntegration.sourceAlbedoStrength,
1953
+ );
1954
+ }
1955
+ // Each replaceable top layer owns its complete surface response. Applying
1956
+ // these in the same grass -> snow -> sand order as color guarantees that a
1957
+ // sand selection cannot retain grass color, roughness, metalness, or normal.
1958
+ for (const [layer, roughnessMap] of [
1959
+ [resolvedProfile.layers.grass, textures.grassRoughness],
1960
+ [resolvedProfile.layers.snow, null],
1961
+ [resolvedProfile.layers.sand, null],
1962
+ ]) {
1963
+ if (!layer.enabled) continue;
1964
+ let layerRoughness = layer.roughness >= 0
1965
+ ? float(Math.min(1, Math.max(0, layer.roughness)))
1966
+ : roughnessNode;
1967
+ if (roughnessMap?.isTexture) {
1968
+ layerRoughness = clamp(
1969
+ toonLabTriplanarColor(
1970
+ roughnessMap,
1971
+ layer.scale,
1972
+ 1,
1973
+ resolvedProfile.coordinates,
1974
+ ).r,
1975
+ 0,
1976
+ 1,
1977
+ );
1978
+ }
1979
+ roughnessNode = mix(roughnessNode, layerRoughness, topMask);
1980
+ if (layer.metalness >= 0) {
1981
+ metalnessNode = mix(
1982
+ metalnessNode,
1983
+ float(Math.min(1, Math.max(0, layer.metalness))),
1984
+ topMask,
1985
+ );
1986
+ }
1987
+ // Source sublayers expose no separate specular map; replacing the layer
1988
+ // therefore resets dielectric intensity instead of leaking stone response.
1989
+ specularIntensityNode = mix(specularIntensityNode, float(1), topMask);
1990
+ }
1344
1991
  if (useGroundTopSurface) {
1345
1992
  const groundSurface = sampleGroundSurface(positionWorld);
1346
1993
  const groundSurfaceMask = topMask.mul(groundSurface.a);
@@ -1367,13 +2014,52 @@ export function createToonRockMaterial({
1367
2014
  || (resolvedProfile.normals.useSmoothed && Boolean(textures.stylizedNormal))
1368
2015
  || hasSandNormalResponse;
1369
2016
  if (hasNormalResponse) material.normalNode = tangentNormalToView(combinedNormal);
2017
+ // The moss cushion's relief, applied in WORLD space after the tangent chain
2018
+ // rather than inside it. The cushion field is world-space fBm and the mask is
2019
+ // built from world-space terms, so expressing the tilt in tangent space would
2020
+ // mean routing a world gradient through a TBN that some authored rock UV
2021
+ // islands cannot supply — the same degenerate-tangent hazard the guard above
2022
+ // exists for. Perturbing the geometric normal instead needs no tangents at
2023
+ // all, which also lets the cushion be the sole normal response on an asset
2024
+ // that binds no normal map.
2025
+ if (mossMaskNode && mossCushionNode && resolvedProfile.moss.cushion > 0) {
2026
+ const surface = normalWorldGeometry;
2027
+ // Keep only the component of the gradient lying in the surface's tangent
2028
+ // plane; the part along the normal would scale the normal rather than tilt
2029
+ // it, which is not a slope.
2030
+ const tangential = mossCushionNode.sub(
2031
+ surface.mul(dot(mossCushionNode, surface)),
2032
+ );
2033
+ // Downhill on the height field means the surface tips that way, hence the
2034
+ // subtraction. Scaled by the mask so bare stone is untouched and the tilt
2035
+ // fades in exactly where the cushion swells.
2036
+ const cushionWorld = normalize(
2037
+ surface.sub(tangential.mul(
2038
+ mossMaskNode.mul(resolvedProfile.moss.cushion * 1.6),
2039
+ )),
2040
+ );
2041
+ // Two arguments, not one: the second is the matrix to transform BY. The
2042
+ // cushion normal is world-space, so that is the camera view matrix.
2043
+ const cushionView = transformNormalByViewMatrix(cushionWorld, cameraViewMatrix);
2044
+ material.normalNode = hasNormalResponse
2045
+ ? normalize(mix(material.normalNode, cushionView, mossMaskNode))
2046
+ : cushionView;
2047
+ }
1370
2048
  material.metalnessNode = metalnessNode;
1371
2049
  // TOONLAB exposes smoothness while Three exposes roughness.
1372
2050
  material.roughnessNode = roughnessNode;
1373
2051
  // TOONLAB metallic workflow's dielectric reflectance is 0.04, matching IOR 1.5.
1374
2052
  material.iorNode = float(1.5);
1375
2053
  material.specularIntensityNode = specularIntensityNode;
1376
- material.emissiveNode = layerState.emission;
2054
+ const sourceEmission = textures.sourceEmissive?.isTexture
2055
+ ? texture(textures.sourceEmissive).sample(uv()).rgb.mul(
2056
+ vec3(...resolvedAssetIntegration.sourceEmissive),
2057
+ ).mul(resolvedAssetIntegration.sourceEmissiveIntensity)
2058
+ : vec3(...resolvedAssetIntegration.sourceEmissive)
2059
+ .mul(resolvedAssetIntegration.sourceEmissiveIntensity);
2060
+ material.emissiveNode = layerState.emission.add(
2061
+ sourceEmission.mul(resolvedAssetIntegration.sourceAlbedoStrength),
2062
+ );
1377
2063
  const vertexAoNode = resolvedAssetIntegration.vertexAoStrength > 0
1378
2064
  ? clamp(
1379
2065
  mix(
@@ -1385,27 +2071,55 @@ export function createToonRockMaterial({
1385
2071
  1,
1386
2072
  )
1387
2073
  : float(1);
2074
+ const primaryAoNode = textures.rockAo?.isTexture
2075
+ ? clamp(toonLabRockProjection(textures.rockAo, resolvedProfile).r, 0, 1)
2076
+ : float(1);
2077
+ const authoredAoNode = vertexAoNode.mul(primaryAoNode);
1388
2078
  material.aoNode = (
1389
2079
  resolvedAssetIntegration.sourceAoStrength > 0
1390
2080
  && textures.sourceAo?.isTexture
1391
2081
  )
1392
- ? vertexAoNode.mul(mix(
2082
+ ? authoredAoNode.mul(mix(
1393
2083
  float(1),
1394
2084
  texture(textures.sourceAo).sample(uv()).r,
1395
2085
  resolvedAssetIntegration.sourceAoStrength,
1396
2086
  ))
1397
- : vertexAoNode;
1398
- material.transparent = false;
1399
- material.opacity = 1;
2087
+ : authoredAoNode;
2088
+ const usesSourceAlpha = resolvedAssetIntegration.sourceAlphaTest > 0
2089
+ || resolvedAssetIntegration.sourceTransparent
2090
+ || resolvedAssetIntegration.sourceOpacity < 1;
2091
+ if (usesSourceAlpha) {
2092
+ material.opacityNode = textures.sourceRock?.isTexture
2093
+ ? texture(textures.sourceRock).sample(uv()).a.mul(resolvedAssetIntegration.sourceOpacity)
2094
+ : float(resolvedAssetIntegration.sourceOpacity);
2095
+ material.alphaTestNode = resolvedAssetIntegration.sourceAlphaTest > 0
2096
+ ? float(resolvedAssetIntegration.sourceAlphaTest)
2097
+ : null;
2098
+ }
2099
+ material.transparent = usesSourceAlpha && resolvedAssetIntegration.sourceTransparent;
2100
+ material.opacity = usesSourceAlpha ? resolvedAssetIntegration.sourceOpacity : 1;
1400
2101
  material.alphaTest = 0;
1401
- material.depthWrite = true;
2102
+ material.depthWrite = usesSourceAlpha ? resolvedAssetIntegration.sourceDepthWrite : true;
2103
+ if (resolvedAssetIntegration.sourceSide !== null) material.side = resolvedAssetIntegration.sourceSide;
1402
2104
  material.userData.toonLabRockProfile = resolvedProfile;
1403
2105
  material.userData.toonLabRockSceneState = {
2106
+ setSkyColor(value) {
2107
+ const channels = value?.isColor
2108
+ ? [value.r, value.g, value.b]
2109
+ : Array.isArray(value) || ArrayBuffer.isView(value)
2110
+ ? Array.from(value).slice(0, 3).map(Number)
2111
+ : null;
2112
+ if (channels?.length === 3 && channels.every(Number.isFinite)) {
2113
+ sceneSkyColor.value.setRGB(...channels);
2114
+ }
2115
+ return sceneSkyColor.value.toArray();
2116
+ },
1404
2117
  setWaterLevel(value) {
1405
2118
  const next = Number(value);
1406
2119
  if (Number.isFinite(next)) sceneWaterLevel.value = next;
1407
2120
  return sceneWaterLevel.value;
1408
2121
  },
2122
+ skyColor: sceneSkyColor,
1409
2123
  waterLevel: sceneWaterLevel,
1410
2124
  };
1411
2125
  material.userData.toonLabRockAssetIntegration = resolvedAssetIntegration;
@@ -1450,9 +2164,38 @@ export function createToonRockMaterial({
1450
2164
  keepsTextures: true,
1451
2165
  vertexDeformation: false,
1452
2166
  });
2167
+ const warmSkyBlend = smoothstep(
2168
+ float(0),
2169
+ float(0.25),
2170
+ sceneSkyColor.r.sub(sceneSkyColor.b),
2171
+ );
2172
+ const preserveProbeChroma = max(warmSkyBlend, lowLightProbeBlend);
1453
2173
  installToonLabSurfaceLighting(material, {
2174
+ // Keep geological Base Color invariant. The captured intensity-8 sun still
2175
+ // needs the historical rock calibration, but applying it to albedo also
2176
+ // crushed the sky probe and every shaded face. Direct light owns that
2177
+ // calibration now; indirect light and the readability floor see the real
2178
+ // material color.
2179
+ directStrength: resolvedProfile.lighting.exposure,
1454
2180
  indirectStrength: resolvedProfile.lighting.skyFillStrength,
1455
2181
  indirectTint: resolvedProfile.lighting.skyFillTint,
2182
+ // The scene probe supplies irradiance magnitude, while this live uniform
2183
+ // supplies its visible time-of-day colour. This preserves geological Base
2184
+ // Color and makes the same shaded face blue at noon, warm at sunset, and
2185
+ // deep blue at night without rebuilding or retexturing the material.
2186
+ // A bright neutral daytime probe receives visible-sky chroma. Low-light
2187
+ // and warm-hour probes already contain their authored colour, so preserve
2188
+ // them instead of multiplying that colour a second time. Double-tinting
2189
+ // the dark-blue night probe crushed red sandstone almost to black.
2190
+ indirectTintNode: mix(skyChroma, vec3(1), preserveProbeChroma)
2191
+ .mul(vec3(...resolvedProfile.lighting.skyFillTint)),
2192
+ indirectTintMode: 'multiply',
2193
+ shadowFill: resolvedProfile.lighting.shadowFill,
2194
+ shadowFillTint: resolvedProfile.lighting.shadowFillTint,
2195
+ // Native directional shadows already cover these meshes. Re-sampling the
2196
+ // package depth atlas here double-shadowed the full rock at sunset; cloud
2197
+ // transmittance remains shared inside the lighting bridge.
2198
+ useSharedSunShadow: false,
1456
2199
  workflow: 'metallic',
1457
2200
  });
1458
2201
  return material;