@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
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
// Geometry-side surface detail for rock meshes.
|
|
2
|
+
//
|
|
3
|
+
// A detail normal map can only shade relief that the silhouette never shows.
|
|
4
|
+
// Published catalog cliffs are 1.5k-4k triangles, which is ample at mid
|
|
5
|
+
// distance and visibly faceted in a close framing: large planar spans and hard
|
|
6
|
+
// straight silhouette edges that no normal map can break up, because the
|
|
7
|
+
// geometry genuinely is flat there.
|
|
8
|
+
//
|
|
9
|
+
// This module adds the missing geometry, deterministically and at load time:
|
|
10
|
+
// subdivide, then displace along the interpolated normal with a value-noise
|
|
11
|
+
// fBm evaluated in the mesh's own local space.
|
|
12
|
+
//
|
|
13
|
+
// Two decisions worth stating, because the obvious implementations are wrong:
|
|
14
|
+
//
|
|
15
|
+
// * Normals are NOT recomputed with `computeVertexNormals()`. Subdivision
|
|
16
|
+
// output is non-indexed, so that call produces flat per-face normals and
|
|
17
|
+
// makes the faceting worse than it started. Welding by position instead
|
|
18
|
+
// over-smooths: a rock's crisp arris edges are load-bearing in the
|
|
19
|
+
// stylized read, and averaging across them rounds the asset into a pebble.
|
|
20
|
+
// The original normals are interpolated through subdivision and then
|
|
21
|
+
// perturbed by the *gradient of the same height field* that moved the
|
|
22
|
+
// vertices, so shading stays consistent with the new surface while every
|
|
23
|
+
// authored hard edge survives.
|
|
24
|
+
//
|
|
25
|
+
// * Displacement is evaluated in local space, not world space. A rock's
|
|
26
|
+
// surface must not swim when the scene moves it, and keying the field to
|
|
27
|
+
// `variation` is what makes two placements of the same geology carry
|
|
28
|
+
// different relief — the per-asset separation that shared texture maps
|
|
29
|
+
// cannot provide.
|
|
30
|
+
//
|
|
31
|
+
// Nothing here touches the catalog artifact; it operates on the loaded mesh.
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Displacement defaults, tuned against shot S08's 85 mm framing on
|
|
35
|
+
* ROCK-COAST-01 — the closest any launch frame gets to a rock.
|
|
36
|
+
*
|
|
37
|
+
* `amount` is in metres and the useful window is narrow. At 0.055 the straight
|
|
38
|
+
* silhouette edges soften but the broad faces stay visibly planar; at 0.16 with
|
|
39
|
+
* a 0.40 scale the asset turns to lumpy wax and the crisp stylized arris that
|
|
40
|
+
* makes it read as cliff rather than boulder is gone. 0.10 m at a 0.65 m
|
|
41
|
+
* feature scale erodes the edges and breaks up the faces while keeping the
|
|
42
|
+
* authored silhouette legible.
|
|
43
|
+
*
|
|
44
|
+
* `subdivisions: 2` (16x triangles) resolves this scale almost as well as 3
|
|
45
|
+
* (64x) at a quarter of the cost, so 3 is reserved for a hero close-up.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_ROCK_GEOMETRY_DETAIL = Object.freeze({
|
|
48
|
+
amount: 0.1,
|
|
49
|
+
// Concavity channel for the moss mask. `cavityMicro` folds the displacement's
|
|
50
|
+
// own pits into the macro curvature, so moss gathers in the fine hollows the
|
|
51
|
+
// noise just carved as well as in the mesh's structural crevices.
|
|
52
|
+
cavity: true,
|
|
53
|
+
cavityGain: 2.6,
|
|
54
|
+
cavityMicro: 0.55,
|
|
55
|
+
lacunarity: 2.03,
|
|
56
|
+
normalStrength: 1,
|
|
57
|
+
octaves: 4,
|
|
58
|
+
scale: 0.65,
|
|
59
|
+
subdivisions: 2,
|
|
60
|
+
variation: 0,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** Subdivision is 4^levels triangles; past this a hero mesh stops being sane. */
|
|
64
|
+
export const MAX_ROCK_DETAIL_SUBDIVISIONS = 3;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Displacement as a fraction of the coarsest feature size.
|
|
68
|
+
*
|
|
69
|
+
* The reference pairing (0.1 m displacement at a 0.65 m feature scale) is
|
|
70
|
+
* 0.154, so this preserves the tuned cliff response exactly while making the
|
|
71
|
+
* relationship the derivation actually depends on explicit.
|
|
72
|
+
*/
|
|
73
|
+
const FEATURE_RELIEF_FRACTION = DEFAULT_ROCK_GEOMETRY_DETAIL.amount
|
|
74
|
+
/ DEFAULT_ROCK_GEOMETRY_DETAIL.scale;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The size the defaults were tuned against: ROCK-COAST-01 at 5.94 m.
|
|
78
|
+
* `amount` and `scale` are absolute metres, so they only mean what they were
|
|
79
|
+
* tuned to mean on a rock of about this size.
|
|
80
|
+
*/
|
|
81
|
+
export const ROCK_GEOMETRY_DETAIL_REFERENCE = Object.freeze({
|
|
82
|
+
amount: DEFAULT_ROCK_GEOMETRY_DETAIL.amount,
|
|
83
|
+
scale: DEFAULT_ROCK_GEOMETRY_DETAIL.scale,
|
|
84
|
+
size: 5.94,
|
|
85
|
+
/** ROCK-COAST-01's LOD0 count, which sets the reference facet size. */
|
|
86
|
+
triangles: 4006,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Relief multipliers by stone character.
|
|
91
|
+
*
|
|
92
|
+
* A fractured cliff and a water-worn garden boulder are not the same surface
|
|
93
|
+
* at different sizes — the boulder is smoother *in proportion to itself*,
|
|
94
|
+
* because the process that shaped it removed relief rather than creating it.
|
|
95
|
+
* Scaling the cliff numbers geometrically onto a 1.3 m boulder gives a lumpy
|
|
96
|
+
* potato; these are the honest per-character corrections.
|
|
97
|
+
*/
|
|
98
|
+
export const ROCK_GEOMETRY_DETAIL_CHARACTER = Object.freeze({
|
|
99
|
+
/** Fractured, bedded, quarried — the tuned cliff response. */
|
|
100
|
+
fractured: 1,
|
|
101
|
+
/** Glacially rounded or water-worn: soft convex form, shallow relief. */
|
|
102
|
+
worn: 0.42,
|
|
103
|
+
/** Weathered but still angular: talus, frost-shattered fragments. */
|
|
104
|
+
weathered: 0.72,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detail options for a rock of a given measured size.
|
|
109
|
+
*
|
|
110
|
+
* `amount` and `scale` are metres, so the tuned defaults silently mean
|
|
111
|
+
* something different on every asset that is not 5.94 m across. This is the
|
|
112
|
+
* geometry-side twin of `resolveCatalogRockProjectionScale`: hold the relief
|
|
113
|
+
* *proportional to the stone* instead of holding the absolute displacement,
|
|
114
|
+
* which is what keeps a 0.6 m stepping stone and a 6 m cliff reading as the
|
|
115
|
+
* same material.
|
|
116
|
+
*
|
|
117
|
+
* `subdivisions` is derived from a triangle target rather than fixed, because
|
|
118
|
+
* the catalog's density is wildly uneven — garden-scale families ship
|
|
119
|
+
* 120-460 triangles where cliff families ship 1,500-4,000, so one fixed level
|
|
120
|
+
* count either starves the small assets or wastes budget on the large ones.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} options
|
|
123
|
+
* @param {number|number[]} [options.size] Largest dimension in metres, or a bounds triple.
|
|
124
|
+
* @param {string} [options.character] Key of `ROCK_GEOMETRY_DETAIL_CHARACTER`.
|
|
125
|
+
* @param {number} [options.relief] Explicit relief multiplier; wins over `character`.
|
|
126
|
+
* @param {number} [options.triangles] Source triangle count, for the subdivision target.
|
|
127
|
+
* @param {number} [options.triangleTarget] Desired post-subdivision triangles.
|
|
128
|
+
* @param {number} [options.variation] Per-asset decorrelation index.
|
|
129
|
+
* @param {number} [options.maxSubdivisions] Ceiling on subdivision levels.
|
|
130
|
+
* @returns {RockGeometryDetailOptions} options ready for `applyRockGeometryDetail`.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveRockGeometryDetailForSize({
|
|
133
|
+
size = ROCK_GEOMETRY_DETAIL_REFERENCE.size,
|
|
134
|
+
character = 'fractured',
|
|
135
|
+
relief = Number.NaN,
|
|
136
|
+
triangles = 0,
|
|
137
|
+
triangleTarget = 2600,
|
|
138
|
+
variation = 0,
|
|
139
|
+
maxSubdivisions = MAX_ROCK_DETAIL_SUBDIVISIONS,
|
|
140
|
+
} = {}) {
|
|
141
|
+
const largest = Array.isArray(size)
|
|
142
|
+
? Math.max(...size.map((value) => Math.abs(Number(value) || 0)))
|
|
143
|
+
: Math.abs(Number(size) || 0);
|
|
144
|
+
const span = largest > 0 ? largest : ROCK_GEOMETRY_DETAIL_REFERENCE.size;
|
|
145
|
+
const ratio = span / ROCK_GEOMETRY_DETAIL_REFERENCE.size;
|
|
146
|
+
const character_ = ROCK_GEOMETRY_DETAIL_CHARACTER[character];
|
|
147
|
+
const reliefScale = Number.isFinite(relief)
|
|
148
|
+
? Math.max(0, relief)
|
|
149
|
+
: (Number.isFinite(character_) ? character_ : 1);
|
|
150
|
+
|
|
151
|
+
const ceiling = Math.max(0, Math.min(MAX_ROCK_DETAIL_SUBDIVISIONS, Math.trunc(maxSubdivisions)));
|
|
152
|
+
let subdivisions = 0;
|
|
153
|
+
const source = Math.max(0, Math.trunc(Number(triangles) || 0));
|
|
154
|
+
if (source > 0 && triangleTarget > 0) {
|
|
155
|
+
while (subdivisions < ceiling && source * (4 ** subdivisions) < triangleTarget) {
|
|
156
|
+
subdivisions += 1;
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
subdivisions = Math.min(ceiling, DEFAULT_ROCK_GEOMETRY_DETAIL.subdivisions);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Relief is keyed to FACET SIZE, not to the asset span, and the difference
|
|
163
|
+
// is not academic.
|
|
164
|
+
//
|
|
165
|
+
// Scaling the tuned 0.1 m by span alone gives 9 mm on a 1.3 m boulder. That
|
|
166
|
+
// is invisible, because what has to be eroded is not the rock — it is the
|
|
167
|
+
// flat spans of its hull, and midpoint subdivision splits triangles without
|
|
168
|
+
// moving them, so a 120-triangle hull is still a 120-facet hull at 7,680
|
|
169
|
+
// triangles. Facet size is `span / sqrt(triangles)`, and by that measure the
|
|
170
|
+
// 1.28 m / 120-triangle boulder has facets of 0.117 m against the 5.94 m /
|
|
171
|
+
// 4,006-triangle cliff's 0.094 m — LARGER, despite being a fifth the size.
|
|
172
|
+
// Span-derived relief had it backwards and flattened exactly the assets that
|
|
173
|
+
// needed the most help.
|
|
174
|
+
const referenceFacet = ROCK_GEOMETRY_DETAIL_REFERENCE.size
|
|
175
|
+
/ Math.sqrt(ROCK_GEOMETRY_DETAIL_REFERENCE.triangles);
|
|
176
|
+
const facet = source > 0 ? span / Math.sqrt(source) : referenceFacet * ratio;
|
|
177
|
+
const facetRatio = referenceFacet > 0 ? facet / referenceFacet : 1;
|
|
178
|
+
const featureScale = ROCK_GEOMETRY_DETAIL_REFERENCE.scale * facetRatio;
|
|
179
|
+
return {
|
|
180
|
+
amount: featureScale * FEATURE_RELIEF_FRACTION * reliefScale,
|
|
181
|
+
cavity: DEFAULT_ROCK_GEOMETRY_DETAIL.cavity,
|
|
182
|
+
cavityGain: DEFAULT_ROCK_GEOMETRY_DETAIL.cavityGain,
|
|
183
|
+
cavityMicro: DEFAULT_ROCK_GEOMETRY_DETAIL.cavityMicro,
|
|
184
|
+
lacunarity: DEFAULT_ROCK_GEOMETRY_DETAIL.lacunarity,
|
|
185
|
+
normalStrength: DEFAULT_ROCK_GEOMETRY_DETAIL.normalStrength,
|
|
186
|
+
octaves: DEFAULT_ROCK_GEOMETRY_DETAIL.octaves,
|
|
187
|
+
scale: featureScale,
|
|
188
|
+
// Water-worn stone has no arris edges to protect — the process that made
|
|
189
|
+
// it removed them. Smoothing is wrong for a fractured cliff and right
|
|
190
|
+
// here, and without it a 120-facet catalog boulder shades as a gemstone.
|
|
191
|
+
smoothNormals: character === 'worn' && !Number.isFinite(relief),
|
|
192
|
+
subdivisions,
|
|
193
|
+
variation: Math.trunc(Number(variation) || 0),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function hash3(x, y, z) {
|
|
198
|
+
let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(z | 0, 2147483647);
|
|
199
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
200
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function smoothstep01(t) {
|
|
204
|
+
return t * t * (3 - (2 * t));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Trilinear value noise in [0,1]. */
|
|
208
|
+
function valueNoise3(x, y, z) {
|
|
209
|
+
const xi = Math.floor(x);
|
|
210
|
+
const yi = Math.floor(y);
|
|
211
|
+
const zi = Math.floor(z);
|
|
212
|
+
const tx = smoothstep01(x - xi);
|
|
213
|
+
const ty = smoothstep01(y - yi);
|
|
214
|
+
const tz = smoothstep01(z - zi);
|
|
215
|
+
let result = 0;
|
|
216
|
+
for (let dz = 0; dz <= 1; dz += 1) {
|
|
217
|
+
const wz = dz ? tz : 1 - tz;
|
|
218
|
+
for (let dy = 0; dy <= 1; dy += 1) {
|
|
219
|
+
const wy = dy ? ty : 1 - ty;
|
|
220
|
+
for (let dx = 0; dx <= 1; dx += 1) {
|
|
221
|
+
const wx = dx ? tx : 1 - tx;
|
|
222
|
+
result += hash3(xi + dx, yi + dy, zi + dz) * wx * wy * wz;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Displacement controls. Declared explicitly because inferring them from the
|
|
231
|
+
* frozen defaults yields literal types (`scale?: 0.65`), which would reject
|
|
232
|
+
* every real caller value and force a permissive declaration fallback.
|
|
233
|
+
*
|
|
234
|
+
* @typedef {object} RockGeometryDetailOptions
|
|
235
|
+
* @property {number} [amount] Peak displacement along the normal, metres.
|
|
236
|
+
* @property {boolean} [cavity] Write the concavity channel the moss mask reads.
|
|
237
|
+
* @property {number} [cavityGain] Scales the curvature response before clamping.
|
|
238
|
+
* @property {number} [cavityMicro] Folds displacement pits into the macro curvature.
|
|
239
|
+
* @property {number} [lacunarity] Frequency step between octaves.
|
|
240
|
+
* @property {number} [normalStrength] Scales the gradient-driven normal tilt.
|
|
241
|
+
* @property {number} [octaves] fBm octave count.
|
|
242
|
+
* @property {number} [scale] Coarsest feature size, metres.
|
|
243
|
+
* @property {boolean} [smoothNormals] Average normals across coincident vertices first.
|
|
244
|
+
* @property {number} [subdivisions] Midpoint subdivision levels (4^n triangles).
|
|
245
|
+
* @property {number} [variation] Per-asset index; equal indices give equal relief.
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @typedef {object} RockDetailHeightOptions
|
|
250
|
+
* @property {number} [lacunarity]
|
|
251
|
+
* @property {number} [octaves]
|
|
252
|
+
* @property {number} [scale]
|
|
253
|
+
* @property {number} [seed]
|
|
254
|
+
*/
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Signed fBm height in roughly [-1, 1].
|
|
258
|
+
*
|
|
259
|
+
* `seed` offsets the lattice rather than reseeding the hash, which keeps the
|
|
260
|
+
* field continuous and makes adjacent variation indices genuinely different
|
|
261
|
+
* surfaces instead of near-copies.
|
|
262
|
+
*
|
|
263
|
+
* @param {number} x
|
|
264
|
+
* @param {number} y
|
|
265
|
+
* @param {number} z
|
|
266
|
+
* @param {RockDetailHeightOptions} [options]
|
|
267
|
+
* @returns {number}
|
|
268
|
+
*/
|
|
269
|
+
export function rockDetailHeight(x, y, z, {
|
|
270
|
+
lacunarity = DEFAULT_ROCK_GEOMETRY_DETAIL.lacunarity,
|
|
271
|
+
octaves = DEFAULT_ROCK_GEOMETRY_DETAIL.octaves,
|
|
272
|
+
scale = DEFAULT_ROCK_GEOMETRY_DETAIL.scale,
|
|
273
|
+
seed = 0,
|
|
274
|
+
} = {}) {
|
|
275
|
+
const invScale = 1 / Math.max(1e-4, scale);
|
|
276
|
+
const offset = (seed % 64) * 17.31;
|
|
277
|
+
let frequency = invScale;
|
|
278
|
+
let amplitude = 1;
|
|
279
|
+
let total = 0;
|
|
280
|
+
let normalization = 0;
|
|
281
|
+
for (let octave = 0; octave < octaves; octave += 1) {
|
|
282
|
+
total += amplitude * valueNoise3(
|
|
283
|
+
(x * frequency) + offset,
|
|
284
|
+
(y * frequency) + (offset * 0.7),
|
|
285
|
+
(z * frequency) + (offset * 1.3),
|
|
286
|
+
);
|
|
287
|
+
normalization += amplitude;
|
|
288
|
+
frequency *= lacunarity;
|
|
289
|
+
amplitude *= 0.5;
|
|
290
|
+
}
|
|
291
|
+
return ((total / normalization) * 2) - 1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Vertex attribute carrying the concavity term the moss mask reads. */
|
|
295
|
+
export const ROCK_CAVITY_ATTRIBUTE = 'rockCavity';
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Vertex attribute carrying the CONVEXITY term — the signed opposite of
|
|
299
|
+
* `ROCK_CAVITY_ATTRIBUTE`.
|
|
300
|
+
*
|
|
301
|
+
* `computeMeshCavity` clamps its curvature to [0,1], so every convex vertex —
|
|
302
|
+
* every crown, ridge, shoulder and exposed corner — resolves to exactly 0 and
|
|
303
|
+
* is indistinguishable from a flat face. The moss support term could therefore
|
|
304
|
+
* express "this is a hollow" but had no way to express "this is a weather-beaten
|
|
305
|
+
* crown", and a crown is precisely where moss is absent: it sheds water, it is
|
|
306
|
+
* scoured, and it dries first.
|
|
307
|
+
*
|
|
308
|
+
* That missing half is why moss distributed evenly over exposed upper faces
|
|
309
|
+
* (D19-212). It is the same estimator, the same pass and the same interpolation;
|
|
310
|
+
* only the sign kept is different.
|
|
311
|
+
*/
|
|
312
|
+
export const ROCK_EXPOSURE_ATTRIBUTE = 'rockExposure';
|
|
313
|
+
|
|
314
|
+
function positionKey(x, y, z) {
|
|
315
|
+
// 0.1 mm quantisation: welds the duplicated corners of a non-indexed soup
|
|
316
|
+
// without merging genuinely distinct surfaces.
|
|
317
|
+
return `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Per-vertex concavity of the mesh's own form, in [0,1].
|
|
322
|
+
*
|
|
323
|
+
* Moss does not colonise by slope. It colonises where moisture collects and
|
|
324
|
+
* light is indirect — crevices, hollows, the junction where one slab meets
|
|
325
|
+
* another. Slope cannot express any of that, which is why a slope-only mask
|
|
326
|
+
* reads as a tint painted on rather than a material growing in.
|
|
327
|
+
*
|
|
328
|
+
* The estimator is the standard discrete mean-curvature sign test: for each
|
|
329
|
+
* welded vertex, take the centroid of its one-ring neighbours and project the
|
|
330
|
+
* offset onto the vertex normal. A centroid sitting along +n means the surface
|
|
331
|
+
* curves away on all sides — a hollow. Along -n means a ridge or an exposed
|
|
332
|
+
* corner. Normalising by the local edge length keeps it scale-invariant, so the
|
|
333
|
+
* same term works on a 0.4 m stepping stone and a 6 m cliff.
|
|
334
|
+
*
|
|
335
|
+
* Computed BEFORE subdivision deliberately: the coarse mesh's edges span the
|
|
336
|
+
* macro form, which is where the real crevices are. Subdivision then
|
|
337
|
+
* interpolates the attribute for free, giving a smooth moisture field rather
|
|
338
|
+
* than a stair-stepped one.
|
|
339
|
+
*
|
|
340
|
+
* @param {import('three').BufferGeometry} geometry Non-indexed triangle soup.
|
|
341
|
+
* @param {number} [gain] Scales the curvature response before clamping.
|
|
342
|
+
* @returns {Float32Array} one value per vertex.
|
|
343
|
+
*/
|
|
344
|
+
export function computeMeshCavity(geometry, gain = 2.6) {
|
|
345
|
+
return computeMeshCurvature(geometry, gain).cavity;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Per-vertex concavity AND convexity of the mesh's own form, both in [0,1].
|
|
350
|
+
*
|
|
351
|
+
* One traversal, one curvature estimate, two rectified halves. `cavity` keeps
|
|
352
|
+
* the positive sign (hollows, crevices, slab junctions) and `exposure` keeps the
|
|
353
|
+
* negative one (crowns, ridges, shoulders, exposed corners). They are mutually
|
|
354
|
+
* exclusive per vertex by construction, and a flat face scores 0 in both.
|
|
355
|
+
*
|
|
356
|
+
* Splitting them matters because the two halves answer different questions for
|
|
357
|
+
* the moss mask: cavity says where water lingers, exposure says where it never
|
|
358
|
+
* does. A mask holding only the first can add moss to hollows but cannot take it
|
|
359
|
+
* off a crown, which leaves the noise field as the sole thing distinguishing one
|
|
360
|
+
* upper face from another — and noise uncorrelated with form is the definition
|
|
361
|
+
* of camouflage (D19-212).
|
|
362
|
+
*
|
|
363
|
+
* @param {import('three').BufferGeometry} geometry Non-indexed triangle soup.
|
|
364
|
+
* @param {number} [gain] Scales the curvature response before clamping.
|
|
365
|
+
* @returns {{cavity: Float32Array, exposure: Float32Array}} one value per vertex each.
|
|
366
|
+
*/
|
|
367
|
+
export function computeMeshCurvature(geometry, gain = 2.6) {
|
|
368
|
+
const position = geometry.attributes.position;
|
|
369
|
+
const normal = geometry.attributes.normal;
|
|
370
|
+
const count = position.count;
|
|
371
|
+
const values = new Float32Array(count);
|
|
372
|
+
const exposure = new Float32Array(count);
|
|
373
|
+
if (!normal) return { cavity: values, exposure };
|
|
374
|
+
|
|
375
|
+
/** @type {Map<string, {x: number, y: number, z: number, n: number, edge: number, edges: number}>} */
|
|
376
|
+
const rings = new Map();
|
|
377
|
+
const addNeighbour = (key, nx, ny, nz, edge) => {
|
|
378
|
+
const entry = rings.get(key);
|
|
379
|
+
if (!entry) return;
|
|
380
|
+
entry.x += nx; entry.y += ny; entry.z += nz; entry.n += 1;
|
|
381
|
+
entry.edge += edge; entry.edges += 1;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const keys = new Array(count);
|
|
385
|
+
for (let i = 0; i < count; i += 1) {
|
|
386
|
+
const key = positionKey(position.getX(i), position.getY(i), position.getZ(i));
|
|
387
|
+
keys[i] = key;
|
|
388
|
+
if (!rings.has(key)) rings.set(key, { edge: 0, edges: 0, n: 0, x: 0, y: 0, z: 0 });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (let triangle = 0; triangle < count; triangle += 3) {
|
|
392
|
+
for (let corner = 0; corner < 3; corner += 1) {
|
|
393
|
+
const self = triangle + corner;
|
|
394
|
+
const sx = position.getX(self);
|
|
395
|
+
const sy = position.getY(self);
|
|
396
|
+
const sz = position.getZ(self);
|
|
397
|
+
for (let other = 1; other <= 2; other += 1) {
|
|
398
|
+
const index = triangle + ((corner + other) % 3);
|
|
399
|
+
const ox = position.getX(index);
|
|
400
|
+
const oy = position.getY(index);
|
|
401
|
+
const oz = position.getZ(index);
|
|
402
|
+
addNeighbour(keys[self], ox, oy, oz, Math.hypot(ox - sx, oy - sy, oz - sz));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (let i = 0; i < count; i += 1) {
|
|
408
|
+
const ring = rings.get(keys[i]);
|
|
409
|
+
if (!ring || ring.n === 0 || ring.edges === 0) continue;
|
|
410
|
+
const px = position.getX(i);
|
|
411
|
+
const py = position.getY(i);
|
|
412
|
+
const pz = position.getZ(i);
|
|
413
|
+
const meanEdge = ring.edge / ring.edges;
|
|
414
|
+
if (!(meanEdge > 0)) continue;
|
|
415
|
+
let nx = normal.getX(i);
|
|
416
|
+
let ny = normal.getY(i);
|
|
417
|
+
let nz = normal.getZ(i);
|
|
418
|
+
const length = Math.hypot(nx, ny, nz) || 1;
|
|
419
|
+
nx /= length; ny /= length; nz /= length;
|
|
420
|
+
const dx = (ring.x / ring.n) - px;
|
|
421
|
+
const dy = (ring.y / ring.n) - py;
|
|
422
|
+
const dz = (ring.z / ring.n) - pz;
|
|
423
|
+
const curvature = ((dx * nx) + (dy * ny) + (dz * nz)) / meanEdge;
|
|
424
|
+
values[i] = Math.min(1, Math.max(0, curvature * gain));
|
|
425
|
+
exposure[i] = Math.min(1, Math.max(0, -curvature * gain));
|
|
426
|
+
}
|
|
427
|
+
return { cavity: values, exposure };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Averages normals across coincident vertices, in place.
|
|
432
|
+
*
|
|
433
|
+
* This is the operation the module's header warns against — and it is warned
|
|
434
|
+
* against for FRACTURED stone, where welding rounds the authored arris edges
|
|
435
|
+
* into a pebble. For water-worn stone the reverse is true: the asset has no
|
|
436
|
+
* arris edges to protect, and the catalog's garden-scale families ship flat
|
|
437
|
+
* per-face normals on a 120-460 triangle hull, so the shading breaks into
|
|
438
|
+
* gemstone facets that no amount of subdivision or displacement can hide.
|
|
439
|
+
*
|
|
440
|
+
* Applied BEFORE subdivision, so the interpolation that follows carries a
|
|
441
|
+
* smooth field rather than re-splitting a faceted one.
|
|
442
|
+
*
|
|
443
|
+
* @param {import('three').BufferGeometry} geometry Non-indexed triangle soup.
|
|
444
|
+
* @returns {number} vertices whose normal was replaced.
|
|
445
|
+
*/
|
|
446
|
+
export function smoothMeshNormals(geometry) {
|
|
447
|
+
const position = geometry.attributes.position;
|
|
448
|
+
const normal = geometry.attributes.normal;
|
|
449
|
+
if (!position || !normal) return 0;
|
|
450
|
+
const count = position.count;
|
|
451
|
+
/** @type {Map<string, number[]>} */
|
|
452
|
+
const sums = new Map();
|
|
453
|
+
const keys = new Array(count);
|
|
454
|
+
for (let i = 0; i < count; i += 1) {
|
|
455
|
+
const key = positionKey(position.getX(i), position.getY(i), position.getZ(i));
|
|
456
|
+
keys[i] = key;
|
|
457
|
+
const entry = sums.get(key);
|
|
458
|
+
if (entry) {
|
|
459
|
+
entry[0] += normal.getX(i);
|
|
460
|
+
entry[1] += normal.getY(i);
|
|
461
|
+
entry[2] += normal.getZ(i);
|
|
462
|
+
} else {
|
|
463
|
+
sums.set(key, [normal.getX(i), normal.getY(i), normal.getZ(i)]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
let replaced = 0;
|
|
467
|
+
for (let i = 0; i < count; i += 1) {
|
|
468
|
+
const entry = sums.get(keys[i]);
|
|
469
|
+
if (!entry) continue;
|
|
470
|
+
const length = Math.hypot(entry[0], entry[1], entry[2]);
|
|
471
|
+
if (!(length > 0)) continue;
|
|
472
|
+
normal.setXYZ(i, entry[0] / length, entry[1] / length, entry[2] / length);
|
|
473
|
+
replaced += 1;
|
|
474
|
+
}
|
|
475
|
+
normal.needsUpdate = true;
|
|
476
|
+
return replaced;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function interpolatedAttributeArray(attribute, triangleCount, verticesPerTriangle) {
|
|
480
|
+
const itemSize = attribute.itemSize;
|
|
481
|
+
return new Float32Array(triangleCount * verticesPerTriangle * itemSize);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* One level of midpoint subdivision on a non-indexed geometry: every triangle
|
|
486
|
+
* becomes four. Every float attribute is interpolated, so uvs, colors and any
|
|
487
|
+
* authored channel survive rather than being silently dropped.
|
|
488
|
+
*/
|
|
489
|
+
function subdivideOnce(geometry) {
|
|
490
|
+
const attributes = Object.entries(geometry.attributes)
|
|
491
|
+
.filter(([, attribute]) => attribute?.array && typeof attribute.itemSize === 'number');
|
|
492
|
+
const position = geometry.attributes.position;
|
|
493
|
+
const triangleCount = position.count / 3;
|
|
494
|
+
const next = {};
|
|
495
|
+
for (const [name, attribute] of attributes) {
|
|
496
|
+
next[name] = interpolatedAttributeArray(attribute, triangleCount * 4, 3);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
for (const [name, attribute] of attributes) {
|
|
500
|
+
const size = attribute.itemSize;
|
|
501
|
+
const source = attribute.array;
|
|
502
|
+
const target = next[name];
|
|
503
|
+
let write = 0;
|
|
504
|
+
const emit = (values) => {
|
|
505
|
+
for (let i = 0; i < size; i += 1) target[write + i] = values[i];
|
|
506
|
+
write += size;
|
|
507
|
+
};
|
|
508
|
+
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
|
|
509
|
+
const base = triangle * 3 * size;
|
|
510
|
+
const a = [];
|
|
511
|
+
const b = [];
|
|
512
|
+
const c = [];
|
|
513
|
+
for (let i = 0; i < size; i += 1) {
|
|
514
|
+
a.push(source[base + i]);
|
|
515
|
+
b.push(source[base + size + i]);
|
|
516
|
+
c.push(source[base + (size * 2) + i]);
|
|
517
|
+
}
|
|
518
|
+
const ab = a.map((value, i) => (value + b[i]) * 0.5);
|
|
519
|
+
const bc = b.map((value, i) => (value + c[i]) * 0.5);
|
|
520
|
+
const ca = c.map((value, i) => (value + a[i]) * 0.5);
|
|
521
|
+
emit(a); emit(ab); emit(ca);
|
|
522
|
+
emit(ab); emit(b); emit(bc);
|
|
523
|
+
emit(ca); emit(bc); emit(c);
|
|
524
|
+
emit(ab); emit(bc); emit(ca);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
for (const [name, attribute] of attributes) {
|
|
529
|
+
geometry.setAttribute(name, new attribute.constructor(next[name], attribute.itemSize));
|
|
530
|
+
}
|
|
531
|
+
return geometry;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Subdivides and displaces one geometry in place.
|
|
536
|
+
*
|
|
537
|
+
* @param {import('three').BufferGeometry} geometry
|
|
538
|
+
* @param {RockGeometryDetailOptions} [options]
|
|
539
|
+
* @returns {{triangles: number, subdivisions: number, amount: number, cavity: boolean} | null} applied detail.
|
|
540
|
+
*/
|
|
541
|
+
export function applyRockGeometryDetailToGeometry(geometry, {
|
|
542
|
+
amount = DEFAULT_ROCK_GEOMETRY_DETAIL.amount,
|
|
543
|
+
cavity = DEFAULT_ROCK_GEOMETRY_DETAIL.cavity,
|
|
544
|
+
cavityGain = DEFAULT_ROCK_GEOMETRY_DETAIL.cavityGain,
|
|
545
|
+
cavityMicro = DEFAULT_ROCK_GEOMETRY_DETAIL.cavityMicro,
|
|
546
|
+
lacunarity = DEFAULT_ROCK_GEOMETRY_DETAIL.lacunarity,
|
|
547
|
+
normalStrength = DEFAULT_ROCK_GEOMETRY_DETAIL.normalStrength,
|
|
548
|
+
octaves = DEFAULT_ROCK_GEOMETRY_DETAIL.octaves,
|
|
549
|
+
scale = DEFAULT_ROCK_GEOMETRY_DETAIL.scale,
|
|
550
|
+
smoothNormals = false,
|
|
551
|
+
subdivisions = DEFAULT_ROCK_GEOMETRY_DETAIL.subdivisions,
|
|
552
|
+
variation = 0,
|
|
553
|
+
} = {}) {
|
|
554
|
+
if (!geometry?.attributes?.position) return null;
|
|
555
|
+
const levels = Math.max(0, Math.min(MAX_ROCK_DETAIL_SUBDIVISIONS, Math.trunc(subdivisions)));
|
|
556
|
+
|
|
557
|
+
let working = geometry.index ? geometry.toNonIndexed() : geometry;
|
|
558
|
+
if (working !== geometry) {
|
|
559
|
+
for (const name of Object.keys(geometry.attributes)) geometry.deleteAttribute(name);
|
|
560
|
+
geometry.setIndex(null);
|
|
561
|
+
for (const [name, attribute] of Object.entries(working.attributes)) {
|
|
562
|
+
geometry.setAttribute(name, attribute);
|
|
563
|
+
}
|
|
564
|
+
working.dispose?.();
|
|
565
|
+
working = geometry;
|
|
566
|
+
}
|
|
567
|
+
// Before both the cavity estimate and subdivision: the cavity term projects
|
|
568
|
+
// onto the vertex normal, so it wants the smoothed field too.
|
|
569
|
+
if (smoothNormals) smoothMeshNormals(working);
|
|
570
|
+
// Macro concavity from the coarse form, before subdivision multiplies the
|
|
571
|
+
// vertex count and shrinks the one-ring to micro scale. Subdivision then
|
|
572
|
+
// interpolates it like any other float attribute.
|
|
573
|
+
if (cavity && working.attributes.normal) {
|
|
574
|
+
const CavityArray = working.attributes.position.constructor;
|
|
575
|
+
// Both halves of the same curvature estimate. Written together so the two
|
|
576
|
+
// attributes can never disagree about which mesh they were measured on,
|
|
577
|
+
// and so the runtime's "every mesh carries it" gate covers both at once.
|
|
578
|
+
const curvature = computeMeshCurvature(working, cavityGain);
|
|
579
|
+
working.setAttribute(
|
|
580
|
+
ROCK_CAVITY_ATTRIBUTE,
|
|
581
|
+
new CavityArray(curvature.cavity, 1),
|
|
582
|
+
);
|
|
583
|
+
working.setAttribute(
|
|
584
|
+
ROCK_EXPOSURE_ATTRIBUTE,
|
|
585
|
+
new CavityArray(curvature.exposure, 1),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
for (let level = 0; level < levels; level += 1) subdivideOnce(working);
|
|
590
|
+
|
|
591
|
+
const position = working.attributes.position;
|
|
592
|
+
const normal = working.attributes.normal;
|
|
593
|
+
const cavityAttribute = working.attributes[ROCK_CAVITY_ATTRIBUTE] ?? null;
|
|
594
|
+
const exposureAttribute = working.attributes[ROCK_EXPOSURE_ATTRIBUTE] ?? null;
|
|
595
|
+
const noise = { lacunarity, octaves, scale, seed: Math.trunc(variation) };
|
|
596
|
+
// Finite-difference step for the gradient. Small relative to the finest
|
|
597
|
+
// octave so the perturbation tracks the surface actually being built.
|
|
598
|
+
const epsilon = Math.max(1e-3, scale / (2 ** Math.max(1, octaves)));
|
|
599
|
+
|
|
600
|
+
if (amount !== 0 && normal) {
|
|
601
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
602
|
+
const x = position.getX(i);
|
|
603
|
+
const y = position.getY(i);
|
|
604
|
+
const z = position.getZ(i);
|
|
605
|
+
let nx = normal.getX(i);
|
|
606
|
+
let ny = normal.getY(i);
|
|
607
|
+
let nz = normal.getZ(i);
|
|
608
|
+
const length = Math.hypot(nx, ny, nz) || 1;
|
|
609
|
+
nx /= length; ny /= length; nz /= length;
|
|
610
|
+
|
|
611
|
+
const height = rockDetailHeight(x, y, z, noise);
|
|
612
|
+
position.setXYZ(i, x + (nx * height * amount), y + (ny * height * amount), z + (nz * height * amount));
|
|
613
|
+
|
|
614
|
+
// A vertex pushed inward is a pit; moisture and moss collect there. Fold
|
|
615
|
+
// it into the interpolated macro curvature so the moss mask sees both
|
|
616
|
+
// scales through one channel.
|
|
617
|
+
if (cavityAttribute && cavityMicro > 0) {
|
|
618
|
+
const pit = Math.max(0, -height);
|
|
619
|
+
const merged = cavityAttribute.getX(i) + (pit * cavityMicro);
|
|
620
|
+
cavityAttribute.setX(i, Math.min(1, Math.max(0, merged)));
|
|
621
|
+
}
|
|
622
|
+
// The mirror of the pit: a vertex pushed outward is a micro-bump, which
|
|
623
|
+
// sheds water and dries first exactly as a macro crown does. Folding it in
|
|
624
|
+
// here is what gives the exposure term detail at the displacement's own
|
|
625
|
+
// scale rather than only at the coarse hull's.
|
|
626
|
+
if (exposureAttribute && cavityMicro > 0) {
|
|
627
|
+
const bump = Math.max(0, height);
|
|
628
|
+
const merged = exposureAttribute.getX(i) + (bump * cavityMicro);
|
|
629
|
+
exposureAttribute.setX(i, Math.min(1, Math.max(0, merged)));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (normalStrength !== 0) {
|
|
633
|
+
// Gradient of the height field, then remove its normal component so
|
|
634
|
+
// only the tangential slope tilts the shading normal.
|
|
635
|
+
const gx = (rockDetailHeight(x + epsilon, y, z, noise) - height) / epsilon;
|
|
636
|
+
const gy = (rockDetailHeight(x, y + epsilon, z, noise) - height) / epsilon;
|
|
637
|
+
const gz = (rockDetailHeight(x, y, z + epsilon, noise) - height) / epsilon;
|
|
638
|
+
const along = (gx * nx) + (gy * ny) + (gz * nz);
|
|
639
|
+
const tx = gx - (along * nx);
|
|
640
|
+
const ty = gy - (along * ny);
|
|
641
|
+
const tz = gz - (along * nz);
|
|
642
|
+
const k = amount * normalStrength;
|
|
643
|
+
const px = nx - (tx * k);
|
|
644
|
+
const py = ny - (ty * k);
|
|
645
|
+
const pz = nz - (tz * k);
|
|
646
|
+
const plen = Math.hypot(px, py, pz) || 1;
|
|
647
|
+
normal.setXYZ(i, px / plen, py / plen, pz / plen);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
position.needsUpdate = true;
|
|
651
|
+
normal.needsUpdate = true;
|
|
652
|
+
if (cavityAttribute) cavityAttribute.needsUpdate = true;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
working.computeBoundingBox?.();
|
|
656
|
+
working.computeBoundingSphere?.();
|
|
657
|
+
return {
|
|
658
|
+
amount,
|
|
659
|
+
cavity: Boolean(cavityAttribute),
|
|
660
|
+
subdivisions: levels,
|
|
661
|
+
triangles: position.count / 3,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Adds geometry detail to every rock mesh under `root`.
|
|
667
|
+
*
|
|
668
|
+
* Idempotent per mesh: a geometry already enriched is skipped, so calling this
|
|
669
|
+
* twice cannot compound displacement into mush.
|
|
670
|
+
*
|
|
671
|
+
* @param {import('three').Object3D} root
|
|
672
|
+
* @param {RockGeometryDetailOptions} [options]
|
|
673
|
+
* @returns {{meshes: number, triangles: number, trianglesBefore: number, skipped: number, cavity: number}} meshes that received a cavity channel are counted in `cavity`.
|
|
674
|
+
*/
|
|
675
|
+
export function applyRockGeometryDetail(root, options = {}) {
|
|
676
|
+
const report = { cavity: 0, meshes: 0, skipped: 0, triangles: 0, trianglesBefore: 0 };
|
|
677
|
+
root?.traverse?.((object) => {
|
|
678
|
+
if (!object?.isMesh || !object.geometry) return;
|
|
679
|
+
if (object.userData?.rockShaderExclude === true) return;
|
|
680
|
+
// Catalog artifacts pack every LOD as a sibling node and the consumer hides
|
|
681
|
+
// all but LOD0. Subdividing the hidden ones triples the cost for geometry
|
|
682
|
+
// that is never drawn at this distance.
|
|
683
|
+
if (object.visible === false) {
|
|
684
|
+
report.skipped += 1;
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (object.geometry.userData?.toonLabRockDetail) {
|
|
688
|
+
report.skipped += 1;
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const before = (object.geometry.index?.count ?? object.geometry.attributes.position?.count ?? 0) / 3;
|
|
692
|
+
const applied = applyRockGeometryDetailToGeometry(object.geometry, options);
|
|
693
|
+
if (!applied) return;
|
|
694
|
+
object.geometry.userData = { ...object.geometry.userData, toonLabRockDetail: applied };
|
|
695
|
+
report.meshes += 1;
|
|
696
|
+
if (applied.cavity) report.cavity += 1;
|
|
697
|
+
report.trianglesBefore += before;
|
|
698
|
+
report.triangles += applied.triangles;
|
|
699
|
+
});
|
|
700
|
+
return report;
|
|
701
|
+
}
|