@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.
- package/NPM-LIBRARY.md +230 -0
- package/README.md +78 -36
- package/agents/PROMPTS.md +91 -0
- package/agents/README.md +9 -14
- package/agents/claude/CLAUDE.md +8 -0
- package/agents/codex/AGENTS.md +161 -0
- package/agents/cursor/toonlab.mdc +37 -0
- package/agents/references/mcp-asset-discovery.md +12 -9
- package/agents/references/runtime-entry-points.md +5 -4
- package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
- package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
- package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
- package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
- package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
- package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
- package/cli/toonlab.mjs +3 -2
- package/database/fal-patina.mjs +131 -0
- package/database/generation-service.mjs +108 -7
- package/database/migrations/0007_creation_revisions.sql +80 -0
- package/database/migrations/0008_creation_revision_hardening.sql +98 -0
- package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
- package/database/migrations/0010_external_catalog_assets.sql +28 -0
- package/database/providers.mjs +195 -16
- package/database/repository.mjs +518 -70
- package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
- package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
- package/mcp/public-catalog.mjs +4 -52
- package/mcp/server.mjs +175 -12
- package/mcp/vite-plugin.mjs +78 -2
- package/package.json +32 -12
- package/scripts/generate-catalog-seed.mjs +34 -7
- package/src/asset-policy/catalogLicenses.js +64 -6
- package/src/assetlib/loadImported.js +15 -1
- package/src/catalog/officialCatalogAssetRuntime.js +13 -2
- package/src/catalog/officialCatalogLod.js +34 -1
- package/src/catalog/officialCatalogPlacement.js +29 -14
- package/src/character/animationRetarget.js +2 -0
- package/src/character/characterRig.js +6 -1
- package/src/character/characterRuntime.js +175 -7
- package/src/environment/environmentMaterialAdapter.js +30 -0
- package/src/environment/environmentPresets.js +90 -0
- package/src/environment/environmentSunShadowPass.js +42 -0
- package/src/environment/toonLabSurfaceLighting.js +108 -10
- package/src/environment/urbanPropMaterial.js +104 -2
- package/src/lighting/lightingSystem.js +65 -10
- package/src/renderer/index.js +7 -0
- package/src/renderer/styleComparison.js +985 -0
- package/src/rock-shader/index.js +5 -0
- package/src/rock-shader/rockGeometryDetail.js +701 -0
- package/src/rock-shader/rockMaterial.js +782 -39
- package/src/rock-shader/rockRegionRuntime.js +185 -0
- package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
- package/src/rock-shader/rockShaderRuntime.js +354 -67
- package/src/rock-shader/rockShaderSettings.js +246 -16
- package/src/rock-shader/rockTangentIntegrity.js +136 -0
- package/src/rock-shader/rockTextureIntegrity.js +115 -0
- package/src/rockgen/index.js +1 -0
- package/src/rockgen/rockDocument.js +27 -3
- package/src/rockgen/surface/c7GeologySurface.js +372 -0
- package/src/shaders-tsl/water.js +8 -0
- package/src/sky/cloudShadow.js +25 -1
- package/src/sky/skySystem.js +9 -0
- package/src/styles/index.js +23 -0
- package/src/styles/neutralStylePresets.js +712 -0
- package/src/styles/sceneStyleRuntime.js +51 -4
- package/src/styles/styleAdapters.js +13 -2
- package/src/styles/styleBundle.js +1 -1
- package/src/texgen/evaluateTexture.js +30 -3
- package/src/texgen/textureGenerators.js +63 -31
- package/src/texgen/textureSettings.js +25 -8
- package/src/toon/toonSettings.js +96 -2
- package/src/vegetation/branchTree.js +327 -42
- package/src/vegetation/index.js +24 -2
- package/src/vegetation/scatter.js +372 -1
- package/src/vegetation/stylizedTree.js +312 -126
- package/src/vegetation/stylizedTreeFoliage.js +229 -7
- package/src/vegetation/treeSurfaceTextures.js +328 -0
- package/src/version.js +1 -1
- package/src/water/waterSettings.js +15 -7
- package/types/asset-policy/catalogLicenses.d.ts +2 -0
- package/types/asset-policy/index.d.ts +2 -0
- package/types/assetlib/loadImported.d.ts +3 -1
- package/types/catalog/officialCatalogLod.d.ts +63 -5
- package/types/character/characterRig.d.ts +59 -0
- package/types/character/characterRuntime.d.ts +1 -0
- package/types/environment/environmentMaterialAdapter.d.ts +3 -1
- package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
- package/types/index.d.ts +89 -7
- package/types/lighting/lightingSystem.d.ts +7 -0
- package/types/react/index.d.ts +20 -0
- package/types/renderer/index.d.ts +1 -0
- package/types/renderer/styleComparison.d.ts +409 -0
- package/types/rock-shader/index.d.ts +5 -0
- package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
- package/types/rock-shader/rockMaterial.d.ts +3 -0
- package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
- package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
- package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
- package/types/rock-shader/rockShaderSettings.d.ts +19 -5
- package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
- package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
- package/types/rockgen/index.d.ts +1 -0
- package/types/rockgen/rockDocument.d.ts +29 -1
- package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
- package/types/sky/cloudShadow.d.ts +1 -0
- package/types/styles/index.d.ts +1 -0
- package/types/styles/neutralStylePresets.d.ts +944 -0
- package/types/styles/sceneStyleRuntime.d.ts +20 -0
- package/types/vegetation/branchTree.d.ts +4 -0
- package/types/vegetation/index.d.ts +9 -7
- package/types/vegetation/scatter.d.ts +1 -0
- package/types/vegetation/stylizedTree.d.ts +1 -2
- package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
- package/types/version.d.ts +1 -1
- package/agents/references/geology-playbook.md +0 -118
- package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
- package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
- package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
- package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
- package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
- package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
- package/src/vegetation/legacyTreePresets.js +0 -120
- 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:
|
|
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:
|
|
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:
|
|
489
|
+
wetBandDarkening: clampNumber(
|
|
385
490
|
finite(shoreline.wetBandDarkening, defaults.shoreline.wetBandDarkening),
|
|
386
491
|
0,
|
|
387
492
|
1,
|
|
388
493
|
),
|
|
389
|
-
wetRoughness:
|
|
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
|
-
|
|
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
|
|
1005
|
-
*
|
|
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:
|
|
1184
|
+
sourceAlbedoStrength: clampNumber(
|
|
1019
1185
|
finite(assetIntegration?.sourceAlbedoStrength, 0),
|
|
1020
1186
|
0,
|
|
1021
1187
|
1,
|
|
1022
1188
|
),
|
|
1023
|
-
sourceNormalStrength:
|
|
1189
|
+
sourceNormalStrength: clampNumber(
|
|
1024
1190
|
finite(assetIntegration?.sourceNormalStrength, 1),
|
|
1025
1191
|
0,
|
|
1026
1192
|
2,
|
|
1027
1193
|
),
|
|
1028
|
-
sourceAoStrength:
|
|
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
|
-
|
|
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
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1195
|
-
.
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
2082
|
+
? authoredAoNode.mul(mix(
|
|
1393
2083
|
float(1),
|
|
1394
2084
|
texture(textures.sourceAo).sample(uv()).r,
|
|
1395
2085
|
resolvedAssetIntegration.sourceAoStrength,
|
|
1396
2086
|
))
|
|
1397
|
-
:
|
|
1398
|
-
|
|
1399
|
-
|
|
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;
|