@call-me-sensei/toonlab 0.4.21 → 0.4.22
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 +51 -2
- package/README.md +4 -3
- package/agents/PROMPTS.md +1 -1
- package/database/apply-one-catalog-seed.mjs +63 -0
- package/database/seeds/catalog/0005_2026-08-c8-first12.sql +1101 -0
- package/database/seeds/catalog/0006_2026-09-c8-first100.sql +14245 -0
- package/database/seeds/catalog/0007_2026-09-c8-first100-primary-model.sql +14845 -0
- package/package.json +4 -2
- package/src/catalog/officialCatalog.js +1 -0
- package/src/catalog/officialCatalogAssetRuntime.js +98 -1
- package/src/catalog/officialCatalogLod.js +160 -16
- package/src/catalog/officialCatalogPlacement.js +52 -12
- package/src/catalog/officialCatalogProvider.js +79 -8
- package/src/catalog/officialCatalogRockPackage.js +172 -0
- package/src/rock-shader/rockMaterial.js +205 -28
- package/src/rock-shader/rockShaderRuntime.js +17 -1
- package/src/rock-shader/rockShaderSettings.js +16 -0
- package/src/rockgen/lod/index.js +1 -0
- package/src/rockgen/lod/rockDenseFieldPolicy.js +143 -0
- package/src/rockgen/rockDocument.js +604 -26
- package/src/version.js +1 -1
- package/types/catalog/officialCatalog.d.ts +1 -0
- package/types/catalog/officialCatalogPlacement.d.ts +4 -0
- package/types/catalog/officialCatalogRockPackage.d.ts +68 -0
- package/types/index.d.ts +24 -0
- package/types/rock-shader/rockMaterial.d.ts +3 -0
- package/types/rock-shader/rockShaderSettings.d.ts +2 -0
- package/types/rockgen/lod/index.d.ts +1 -0
- package/types/rockgen/lod/rockDenseFieldPolicy.d.ts +181 -0
- package/types/rockgen/rockDocument.d.ts +579 -68
- package/types/version.d.ts +1 -1
|
@@ -10,8 +10,21 @@ export const OFFICIAL_CATALOG_PROVIDER_TRANSPORTS = Object.freeze([
|
|
|
10
10
|
'public-rock',
|
|
11
11
|
]);
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const LEGACY_ROCK_ID = /^rock-\d{4}$/u;
|
|
14
|
+
const NATURE_REFERENCE_ROCK_ID = /^rock-c8-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
14
15
|
const ROCK_RECIPE_KIND = 'toonlab/rock-recipe';
|
|
16
|
+
const ROCK_GALLERY_RECIPE_SCHEMA = 'toonlab/rock-gallery-recipe';
|
|
17
|
+
|
|
18
|
+
function isRockId(value) {
|
|
19
|
+
return LEGACY_ROCK_ID.test(value) || NATURE_REFERENCE_ROCK_ID.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRockRecipe(value) {
|
|
23
|
+
return isRecord(value) && (
|
|
24
|
+
value.kind === ROCK_RECIPE_KIND
|
|
25
|
+
|| value.schema === ROCK_GALLERY_RECIPE_SCHEMA
|
|
26
|
+
);
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
function isRecord(value) {
|
|
17
30
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -61,6 +74,15 @@ function artifactName(artifact) {
|
|
|
61
74
|
return String(artifact?.name ?? artifact?.relative_path ?? artifact?.relativePath ?? '');
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
function artifactKey(value) {
|
|
78
|
+
return String(value ?? '').trim().replace(/^\.\//u, '').toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function artifactByName(artifacts, ...names) {
|
|
82
|
+
const wanted = new Set(names.map(artifactKey));
|
|
83
|
+
return artifacts.find((artifact) => wanted.has(artifactKey(artifactName(artifact)))) ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
function isModelArtifact(artifact) {
|
|
65
87
|
const name = artifactName(artifact).toLowerCase();
|
|
66
88
|
const contentType = String(artifact?.contentType ?? artifact?.content_type ?? '').toLowerCase();
|
|
@@ -107,7 +129,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
|
|
|
107
129
|
const source = String(rawAsset.source ?? metadata.source ?? expectedSource ?? '').trim();
|
|
108
130
|
const kind = String(rawAsset.kind ?? 'model').trim();
|
|
109
131
|
const catalog = String(metadata.catalog ?? rawAsset.catalog ?? (
|
|
110
|
-
recipe
|
|
132
|
+
isRockRecipe(recipe) ? 'rocks' : ''
|
|
111
133
|
)).trim().toLowerCase();
|
|
112
134
|
const revision = Number(rawAsset.revision ?? metadata.revision);
|
|
113
135
|
const recipeHash = String(rawAsset.recipeHash ?? metadata.recipeHash ?? '').trim();
|
|
@@ -115,7 +137,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
|
|
|
115
137
|
...(Array.isArray(rawAsset.artifacts) ? rawAsset.artifacts : []),
|
|
116
138
|
...(Array.isArray(rawAsset.files) ? rawAsset.files : []),
|
|
117
139
|
].filter(isRecord);
|
|
118
|
-
const modelArtifact = artifacts
|
|
140
|
+
const modelArtifact = artifactByName(artifacts, 'rock.glb')
|
|
119
141
|
?? artifacts.find(isModelArtifact)
|
|
120
142
|
?? null;
|
|
121
143
|
const modelReference = rawAsset.download_url
|
|
@@ -137,9 +159,11 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
|
|
|
137
159
|
if (kind !== 'model') errors.push(`kind must be model, received ${kind || '(empty)'}`);
|
|
138
160
|
if (!modelReference) errors.push('a model download URL is required');
|
|
139
161
|
if (source === 'toonlab-rock') {
|
|
140
|
-
if (!
|
|
162
|
+
if (!isRockId(id)) errors.push('ToonLab rock id must match rock-0001 or rock-c8-geology-subtype');
|
|
141
163
|
if (catalog !== 'rocks') errors.push(`catalog must be rocks, received ${catalog || '(empty)'}`);
|
|
142
|
-
if (recipe
|
|
164
|
+
if (!isRockRecipe(recipe)) {
|
|
165
|
+
errors.push(`recipe must use ${ROCK_RECIPE_KIND} or ${ROCK_GALLERY_RECIPE_SCHEMA}`);
|
|
166
|
+
}
|
|
143
167
|
if (!Number.isInteger(revision) || revision < 1) errors.push('positive integer revision is required');
|
|
144
168
|
if (!recipeHash) errors.push('recipeHash is required');
|
|
145
169
|
}
|
|
@@ -157,15 +181,61 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
|
|
|
157
181
|
sha256: String(artifact.sha256 ?? ''),
|
|
158
182
|
url: resolveOfficialCatalogUrl(artifactUrl(artifact), baseUrl),
|
|
159
183
|
}));
|
|
184
|
+
const artifactMap = Object.freeze(Object.fromEntries(normalizedArtifacts
|
|
185
|
+
.filter((artifact) => artifact.name && artifact.url)
|
|
186
|
+
.map((artifact) => [artifact.name, artifact])));
|
|
187
|
+
const artifactUrlByName = (...names) => {
|
|
188
|
+
const artifact = artifactByName(normalizedArtifacts, ...names);
|
|
189
|
+
return artifact?.url ?? null;
|
|
190
|
+
};
|
|
160
191
|
const release = String(rawAsset.release ?? metadata.release ?? releaseFromUrl(modelUrl) ?? '').trim() || null;
|
|
161
192
|
const domain = catalog === 'rocks' ? 'natural.rock' : String(metadata.domain ?? '').trim() || null;
|
|
162
|
-
const
|
|
193
|
+
const recipeLod = isRecord(recipe?.lod) ? recipe.lod : null;
|
|
194
|
+
const metadataLod = isRecord(rawAsset.lod ?? metadata.lod) ? rawAsset.lod ?? metadata.lod : null;
|
|
195
|
+
const lodLevels = Array.isArray(recipe?.output?.lods)
|
|
196
|
+
? recipe.output.lods.map(String)
|
|
197
|
+
: Array.isArray(metadataLod?.levels)
|
|
198
|
+
? metadataLod.levels.map(String)
|
|
199
|
+
: [];
|
|
200
|
+
const lodUrls = Object.freeze(Array.from({ length: 5 }, (_, level) => (
|
|
201
|
+
artifactUrlByName(`lod${level}.glb`, `lod/LOD${level}.glb`)
|
|
202
|
+
)));
|
|
203
|
+
const hasLodPackage = lodUrls.some(Boolean) || lodLevels.length > 0;
|
|
204
|
+
const lod = recipeLod || metadataLod || hasLodPackage
|
|
205
|
+
? Object.freeze({
|
|
206
|
+
...frozenCopy(recipeLod ?? metadataLod ?? {}),
|
|
207
|
+
cullBelowPixels: Number(
|
|
208
|
+
recipe?.output?.cullBelowPixels
|
|
209
|
+
?? metadataLod?.cullBelowPixels
|
|
210
|
+
?? 0,
|
|
211
|
+
) || 0,
|
|
212
|
+
levels: Object.freeze(lodLevels),
|
|
213
|
+
urls: lodUrls,
|
|
214
|
+
})
|
|
215
|
+
: null;
|
|
216
|
+
const packageFiles = Object.freeze({
|
|
217
|
+
collisionModelUrl: artifactUrlByName('collision.glb'),
|
|
218
|
+
controlModelUrl: artifactUrlByName('control.glb'),
|
|
219
|
+
lodModelUrls: lodUrls,
|
|
220
|
+
manifestUrl: artifactUrlByName('manifest.json'),
|
|
221
|
+
materialConfigUrl: artifactUrlByName('material-config.json'),
|
|
222
|
+
natureProvenanceUrl: artifactUrlByName('nature-provenance.json', 'nature/provenance.json'),
|
|
223
|
+
natureReferenceUrl: artifactUrlByName('nature-reference.jpg', 'nature/reference.jpg'),
|
|
224
|
+
realisticModelUrl: modelUrl,
|
|
225
|
+
realisticPreviewUrl: artifactUrlByName('realistic-preview.png'),
|
|
226
|
+
recipeUrl: artifactUrlByName('recipe.json'),
|
|
227
|
+
retainedHighModelUrl: artifactUrlByName('retained-high.glb', 'high.glb'),
|
|
228
|
+
stylizedModelUrl: artifactUrlByName('rock-stylized.glb'),
|
|
229
|
+
stylizedPreviewUrl: artifactUrlByName('stylized-preview.png'),
|
|
230
|
+
});
|
|
231
|
+
const collisionMetadata = rawAsset.collision ?? metadata.collision;
|
|
163
232
|
|
|
164
233
|
return Object.freeze({
|
|
165
234
|
artifacts: Object.freeze(normalizedArtifacts),
|
|
235
|
+
artifactMap,
|
|
166
236
|
catalog,
|
|
167
|
-
collision: isRecord(
|
|
168
|
-
? frozenCopy(
|
|
237
|
+
collision: isRecord(collisionMetadata) && typeof collisionMetadata.kind === 'string'
|
|
238
|
+
? frozenCopy(collisionMetadata)
|
|
169
239
|
: null,
|
|
170
240
|
domain,
|
|
171
241
|
id,
|
|
@@ -175,6 +245,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
|
|
|
175
245
|
lod,
|
|
176
246
|
metadata: frozenCopy(metadata),
|
|
177
247
|
modelUrl,
|
|
248
|
+
packageFiles,
|
|
178
249
|
provider,
|
|
179
250
|
provenance: Object.freeze({
|
|
180
251
|
modelSha256: String(modelArtifact?.sha256 ?? metadata.modelSha256 ?? ''),
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LinearFilter,
|
|
3
|
+
LinearMipmapLinearFilter,
|
|
4
|
+
NoColorSpace,
|
|
5
|
+
RepeatWrapping,
|
|
6
|
+
SRGBColorSpace,
|
|
7
|
+
TextureLoader,
|
|
8
|
+
} from 'three';
|
|
9
|
+
|
|
10
|
+
export const OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA = 'toonlab/rock-gallery-recipe';
|
|
11
|
+
export const OFFICIAL_ROCK_MATERIAL_SCHEMA = 'toonlab.pro-rock-material';
|
|
12
|
+
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function frozenCopy(value) {
|
|
18
|
+
if (Array.isArray(value)) return Object.freeze(value.map(frozenCopy));
|
|
19
|
+
if (!isRecord(value)) return value;
|
|
20
|
+
return Object.freeze(Object.fromEntries(
|
|
21
|
+
Object.entries(value).map(([key, entry]) => [key, frozenCopy(entry)]),
|
|
22
|
+
));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function artifactName(value) {
|
|
26
|
+
return String(value ?? '').trim().replace(/^\.\//u, '').toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function findOfficialCatalogArtifact(asset, ...names) {
|
|
30
|
+
const wanted = new Set(names.map(artifactName));
|
|
31
|
+
return asset?.artifacts?.find((artifact) => wanted.has(artifactName(artifact.name))) ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getOfficialCatalogArtifactUrl(asset, ...names) {
|
|
35
|
+
return findOfficialCatalogArtifact(asset, ...names)?.url ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function fetchJson(fetchImpl, url, label) {
|
|
39
|
+
if (!url) return null;
|
|
40
|
+
if (typeof fetchImpl !== 'function') throw new TypeError(`${label} requires fetch().`);
|
|
41
|
+
const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
|
|
42
|
+
if (!response?.ok) {
|
|
43
|
+
throw new Error(`${label} returned HTTP ${response?.status ?? 'unknown'}: ${url}`);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return await response.json();
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
throw new Error(`${label} returned invalid JSON: ${url}`, { cause });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateRecipe(asset, recipe) {
|
|
53
|
+
if (!recipe) return null;
|
|
54
|
+
if (recipe.schema !== OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA || recipe.version !== 1) {
|
|
55
|
+
throw new Error(`${asset.id} has an unsupported editable rock recipe.`);
|
|
56
|
+
}
|
|
57
|
+
if (recipe.generator?.unit !== 'metre') {
|
|
58
|
+
throw new Error(`${asset.id} editable recipe must use metres.`);
|
|
59
|
+
}
|
|
60
|
+
return frozenCopy(recipe);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateMaterialConfig(asset, materialConfig) {
|
|
64
|
+
if (!materialConfig) return null;
|
|
65
|
+
if (materialConfig.schema !== OFFICIAL_ROCK_MATERIAL_SCHEMA || materialConfig.version !== 2) {
|
|
66
|
+
throw new Error(`${asset.id} has an unsupported Call Me Sensei material package.`);
|
|
67
|
+
}
|
|
68
|
+
if (materialConfig.assetId !== asset.id || materialConfig.shader?.preset !== 'call_me_sensei') {
|
|
69
|
+
throw new Error(`${asset.id} material package failed identity or preset validation.`);
|
|
70
|
+
}
|
|
71
|
+
if (!isRecord(materialConfig.textures)) {
|
|
72
|
+
throw new Error(`${asset.id} material package has no texture bindings.`);
|
|
73
|
+
}
|
|
74
|
+
return frozenCopy(materialConfig);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load the immutable documents associated with a nature-reference rock.
|
|
79
|
+
* Legacy 480 rocks remain valid and simply return null for documents they do
|
|
80
|
+
* not publish.
|
|
81
|
+
*/
|
|
82
|
+
export async function loadOfficialCatalogRockPackage(asset, {
|
|
83
|
+
fetchImpl = globalThis.fetch?.bind(globalThis),
|
|
84
|
+
includeManifest = false,
|
|
85
|
+
includeNatureProvenance = false,
|
|
86
|
+
} = {}) {
|
|
87
|
+
if (!asset || asset.domain !== 'natural.rock') {
|
|
88
|
+
throw new TypeError('Official rock package loading requires a normalized natural.rock asset.');
|
|
89
|
+
}
|
|
90
|
+
const files = asset.packageFiles ?? {};
|
|
91
|
+
const [recipeDocument, materialDocument, manifest, natureProvenance] = await Promise.all([
|
|
92
|
+
files.recipeUrl ? fetchJson(fetchImpl, files.recipeUrl, `${asset.id} recipe`) : asset.recipe,
|
|
93
|
+
fetchJson(fetchImpl, files.materialConfigUrl, `${asset.id} material package`),
|
|
94
|
+
includeManifest ? fetchJson(fetchImpl, files.manifestUrl, `${asset.id} manifest`) : null,
|
|
95
|
+
includeNatureProvenance
|
|
96
|
+
? fetchJson(fetchImpl, files.natureProvenanceUrl, `${asset.id} nature provenance`)
|
|
97
|
+
: null,
|
|
98
|
+
]);
|
|
99
|
+
const recipe = recipeDocument?.schema === OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA
|
|
100
|
+
? validateRecipe(asset, recipeDocument)
|
|
101
|
+
: frozenCopy(asset.recipe);
|
|
102
|
+
const materialConfig = validateMaterialConfig(asset, materialDocument);
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
asset,
|
|
105
|
+
files,
|
|
106
|
+
manifest: frozenCopy(manifest),
|
|
107
|
+
materialConfig,
|
|
108
|
+
natureProvenance: frozenCopy(natureProvenance),
|
|
109
|
+
recipe,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function configureRockTexture(texture, descriptor, role) {
|
|
114
|
+
texture.name = `ToonLab ${role} · ${descriptor.url}`;
|
|
115
|
+
texture.colorSpace = descriptor.srgb === true ? SRGBColorSpace : NoColorSpace;
|
|
116
|
+
texture.flipY = false;
|
|
117
|
+
texture.wrapS = RepeatWrapping;
|
|
118
|
+
texture.wrapT = RepeatWrapping;
|
|
119
|
+
texture.magFilter = LinearFilter;
|
|
120
|
+
texture.minFilter = LinearMipmapLinearFilter;
|
|
121
|
+
texture.generateMipmaps = true;
|
|
122
|
+
texture.userData.toonlabOfficialRockRole = role;
|
|
123
|
+
texture.userData.toonlabOfficialRockUrl = descriptor.url;
|
|
124
|
+
texture.needsUpdate = true;
|
|
125
|
+
return texture;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Load the exact texture slots consumed by applyRockShader(). */
|
|
129
|
+
export async function loadOfficialCatalogRockShaderInput(rockPackage, {
|
|
130
|
+
textureLoader = new TextureLoader(),
|
|
131
|
+
} = {}) {
|
|
132
|
+
const config = rockPackage?.materialConfig;
|
|
133
|
+
if (!config) return null;
|
|
134
|
+
const textures = {};
|
|
135
|
+
await Promise.all(Object.entries(config.textures).map(async ([role, descriptor]) => {
|
|
136
|
+
if (!descriptor?.url) throw new Error(`${config.assetId}/${role} has no immutable texture URL.`);
|
|
137
|
+
const texture = await textureLoader.loadAsync(descriptor.url);
|
|
138
|
+
textures[role] = configureRockTexture(texture, descriptor, role);
|
|
139
|
+
}));
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
materialConfig: config,
|
|
142
|
+
settings: frozenCopy(config.shader?.settings ?? {}),
|
|
143
|
+
textures: Object.freeze(textures),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Portable hand-off for Rock Lab or another editor. Geometry derivatives are
|
|
149
|
+
* deliberately separate: edits target control.glb, while retained high, LODs,
|
|
150
|
+
* collision, and baked/runtime outputs can be regenerated or replaced.
|
|
151
|
+
*/
|
|
152
|
+
export function createOfficialCatalogRockEditorDescriptor(asset, rockPackage = null) {
|
|
153
|
+
if (!asset || asset.domain !== 'natural.rock') {
|
|
154
|
+
throw new TypeError('Official rock editor descriptors require a normalized natural.rock asset.');
|
|
155
|
+
}
|
|
156
|
+
const recipe = rockPackage?.recipe ?? asset.recipe;
|
|
157
|
+
const files = asset.packageFiles ?? {};
|
|
158
|
+
if (recipe?.schema !== OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA) return null;
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
assetId: asset.id,
|
|
161
|
+
controlModelUrl: files.controlModelUrl,
|
|
162
|
+
cullBelowPixels: asset.lod?.cullBelowPixels ?? recipe.output?.cullBelowPixels ?? 0,
|
|
163
|
+
editing: frozenCopy(recipe.editing ?? {}),
|
|
164
|
+
lodModelUrls: asset.lod?.urls ?? files.lodModelUrls ?? Object.freeze([]),
|
|
165
|
+
materialConfigUrl: files.materialConfigUrl,
|
|
166
|
+
recipe: frozenCopy(recipe),
|
|
167
|
+
recipeUrl: files.recipeUrl,
|
|
168
|
+
retainedHighModelUrl: files.retainedHighModelUrl,
|
|
169
|
+
collisionModelUrl: files.collisionModelUrl,
|
|
170
|
+
worldUnitMetres: recipe.generator?.unit === 'metre' ? 1 : null,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -131,6 +131,8 @@ export const TOONLAB_ROCK_PROFILE_DEFAULTS = Object.freeze({
|
|
|
131
131
|
distanceScale: 1,
|
|
132
132
|
}),
|
|
133
133
|
base: Object.freeze({
|
|
134
|
+
mode: 'triplanar',
|
|
135
|
+
upAxis: 'y',
|
|
134
136
|
scale: 1,
|
|
135
137
|
tint: Object.freeze([1, 1, 1]),
|
|
136
138
|
saturation: 1,
|
|
@@ -335,6 +337,14 @@ function bool(value, fallback) {
|
|
|
335
337
|
return fallback;
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
function projectionMode(value, fallback = 'triplanar') {
|
|
341
|
+
return value === 'directional-bedding' || value === 'triplanar' ? value : fallback;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function projectionUpAxis(value, fallback = 'y') {
|
|
345
|
+
return value === 'x' || value === 'y' || value === 'z' ? value : fallback;
|
|
346
|
+
}
|
|
347
|
+
|
|
338
348
|
function color3(value, fallback) {
|
|
339
349
|
if (Array.isArray(value)) {
|
|
340
350
|
return [
|
|
@@ -401,6 +411,8 @@ export function normalizeToonLabRockProfile(profile = {}) {
|
|
|
401
411
|
),
|
|
402
412
|
},
|
|
403
413
|
base: {
|
|
414
|
+
mode: projectionMode(base.mode, defaults.base.mode),
|
|
415
|
+
upAxis: projectionUpAxis(base.upAxis, defaults.base.upAxis),
|
|
404
416
|
scale: finite(base.scale, defaults.base.scale),
|
|
405
417
|
tint: color3(base.tint, defaults.base.tint),
|
|
406
418
|
saturation: finite(base.saturation, defaults.base.saturation),
|
|
@@ -944,6 +956,87 @@ export function toonLabTriplanarColor(map, scale, blend = 1, coordinates = { zSi
|
|
|
944
956
|
.add(mapNode.sample(projected.xy).rgb.mul(weights.z));
|
|
945
957
|
}
|
|
946
958
|
|
|
959
|
+
const DIRECTIONAL_BEDDING_AXES = Object.freeze({
|
|
960
|
+
x: Object.freeze({
|
|
961
|
+
lateral: Object.freeze([
|
|
962
|
+
Object.freeze({ faceAxis: 'y', uAxis: 'z', uv: 'zx' }),
|
|
963
|
+
Object.freeze({ faceAxis: 'z', uAxis: 'y', uv: 'yx' }),
|
|
964
|
+
]),
|
|
965
|
+
suppressedProjectionAxis: 'x',
|
|
966
|
+
upAxis: 'x',
|
|
967
|
+
}),
|
|
968
|
+
y: Object.freeze({
|
|
969
|
+
lateral: Object.freeze([
|
|
970
|
+
Object.freeze({ faceAxis: 'x', uAxis: 'z', uv: 'zy' }),
|
|
971
|
+
Object.freeze({ faceAxis: 'z', uAxis: 'x', uv: 'xy' }),
|
|
972
|
+
]),
|
|
973
|
+
suppressedProjectionAxis: 'y',
|
|
974
|
+
upAxis: 'y',
|
|
975
|
+
}),
|
|
976
|
+
z: Object.freeze({
|
|
977
|
+
lateral: Object.freeze([
|
|
978
|
+
Object.freeze({ faceAxis: 'x', uAxis: 'y', uv: 'yz' }),
|
|
979
|
+
Object.freeze({ faceAxis: 'y', uAxis: 'x', uv: 'xz' }),
|
|
980
|
+
]),
|
|
981
|
+
suppressedProjectionAxis: 'z',
|
|
982
|
+
upAxis: 'z',
|
|
983
|
+
}),
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Describes the compile-time directional-bedding graph. The returned data is
|
|
988
|
+
* deliberately plain CPU data so authoring tools and focused verifiers can
|
|
989
|
+
* prove which axis is suppressed without compiling a WebGPU pipeline.
|
|
990
|
+
*/
|
|
991
|
+
export function describeToonLabDirectionalBeddingProjection(upAxis = 'y') {
|
|
992
|
+
const resolved = projectionUpAxis(upAxis, 'y');
|
|
993
|
+
const descriptor = DIRECTIONAL_BEDDING_AXES[resolved];
|
|
994
|
+
return {
|
|
995
|
+
lateral: descriptor.lateral.map((entry) => ({ ...entry })),
|
|
996
|
+
suppressedProjectionAxis: descriptor.suppressedProjectionAxis,
|
|
997
|
+
upAxis: descriptor.upAxis,
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function directionalBeddingState(scale, blend, coordinates, upAxis) {
|
|
1002
|
+
const descriptor = DIRECTIONAL_BEDDING_AXES[projectionUpAxis(upAxis, 'y')];
|
|
1003
|
+
const projected = toonLabSourcePosition(coordinates).div(safeScale(scale));
|
|
1004
|
+
const geometryNormal = toonLabSourceGeometryNormal(coordinates);
|
|
1005
|
+
const exponent = float(Math.max(finite(blend, 1), 0.000001));
|
|
1006
|
+
const upWeight = pow(
|
|
1007
|
+
max(abs(geometryNormal[descriptor.upAxis]), TOONLAB_FLOAT_EPSILON),
|
|
1008
|
+
exponent,
|
|
1009
|
+
);
|
|
1010
|
+
const lateralWeights = descriptor.lateral.map(({ faceAxis }) => pow(
|
|
1011
|
+
max(abs(geometryNormal[faceAxis]), TOONLAB_FLOAT_EPSILON),
|
|
1012
|
+
exponent,
|
|
1013
|
+
).add(upWeight.mul(0.5)));
|
|
1014
|
+
const denominator = max(lateralWeights[0].add(lateralWeights[1]), 0.000001);
|
|
1015
|
+
return {
|
|
1016
|
+
descriptor,
|
|
1017
|
+
geometryNormal,
|
|
1018
|
+
projected,
|
|
1019
|
+
weights: lateralWeights.map((weight) => weight.div(denominator)),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export function toonLabDirectionalBeddingColor(
|
|
1024
|
+
map,
|
|
1025
|
+
scale,
|
|
1026
|
+
blend = 1,
|
|
1027
|
+
coordinates = { zSign: 1 },
|
|
1028
|
+
upAxis = 'y',
|
|
1029
|
+
) {
|
|
1030
|
+
const mapNode = texture(map);
|
|
1031
|
+
const state = directionalBeddingState(scale, blend, coordinates, upAxis);
|
|
1032
|
+
const sample = ({ uv: swizzle }) => mapNode.sample(vec2(
|
|
1033
|
+
state.projected[swizzle[0]],
|
|
1034
|
+
state.projected[swizzle[1]],
|
|
1035
|
+
)).rgb;
|
|
1036
|
+
return sample(state.descriptor.lateral[0]).mul(state.weights[0])
|
|
1037
|
+
.add(sample(state.descriptor.lateral[1]).mul(state.weights[1]));
|
|
1038
|
+
}
|
|
1039
|
+
|
|
947
1040
|
function toonLabSideProjection(map, scale, projectionContrast, {
|
|
948
1041
|
negativeScale = true,
|
|
949
1042
|
clampResult = true,
|
|
@@ -966,7 +1059,18 @@ function toonLabSideProjection(map, scale, projectionContrast, {
|
|
|
966
1059
|
}
|
|
967
1060
|
|
|
968
1061
|
function toonLabRockProjection(map, profile) {
|
|
969
|
-
|
|
1062
|
+
// Directional bedding has explicit precedence. `sideOnly` remains the exact
|
|
1063
|
+
// legacy graph for old presets, but cannot re-enable the suppressed top-axis
|
|
1064
|
+
// sample on a bedded C8 surface.
|
|
1065
|
+
return profile.base.mode === 'directional-bedding'
|
|
1066
|
+
? toonLabDirectionalBeddingColor(
|
|
1067
|
+
map,
|
|
1068
|
+
profile.base.scale,
|
|
1069
|
+
profile.base.projectionContrast,
|
|
1070
|
+
profile.coordinates,
|
|
1071
|
+
profile.base.upAxis,
|
|
1072
|
+
)
|
|
1073
|
+
: profile.base.sideOnly
|
|
970
1074
|
? toonLabSideProjection(map, profile.base.scale, profile.base.projectionContrast, {
|
|
971
1075
|
coordinates: profile.coordinates,
|
|
972
1076
|
})
|
|
@@ -993,13 +1097,18 @@ function normalGreenSignForTexture(map, profileSign) {
|
|
|
993
1097
|
return finite(profileSign, 1) * importerSign;
|
|
994
1098
|
}
|
|
995
1099
|
|
|
1100
|
+
function safeNormalizeDirection(direction, fallback = vec3(0, 0, 1)) {
|
|
1101
|
+
const valid = step(float(1e-12), dot(direction, direction));
|
|
1102
|
+
return normalize(mix(fallback, direction, valid));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
996
1105
|
function worldNormalToTangent(worldNormal) {
|
|
997
1106
|
const viewNormal = transformNormalByViewMatrix(worldNormal, cameraViewMatrix);
|
|
998
|
-
return
|
|
1107
|
+
return safeNormalizeDirection(transpose(TBNViewMatrix).mul(viewNormal));
|
|
999
1108
|
}
|
|
1000
1109
|
|
|
1001
1110
|
export function tangentNormalToView(tangentNormal) {
|
|
1002
|
-
return
|
|
1111
|
+
return safeNormalizeDirection(TBNViewMatrix.mul(tangentNormal));
|
|
1003
1112
|
}
|
|
1004
1113
|
|
|
1005
1114
|
export function toonLabTriplanarNormal(map, scale, blend, greenSign, coordinates) {
|
|
@@ -1027,6 +1136,52 @@ export function toonLabTriplanarNormal(map, scale, blend, greenSign, coordinates
|
|
|
1027
1136
|
return worldNormalToTangent(worldNormal);
|
|
1028
1137
|
}
|
|
1029
1138
|
|
|
1139
|
+
function directionalBeddingNormalCandidate(decoded, geometryNormal, entry, upAxis) {
|
|
1140
|
+
const components = {
|
|
1141
|
+
x: geometryNormal.x,
|
|
1142
|
+
y: geometryNormal.y,
|
|
1143
|
+
z: geometryNormal.z,
|
|
1144
|
+
};
|
|
1145
|
+
components[entry.faceAxis] = abs(decoded.z).mul(geometryNormal[entry.faceAxis]);
|
|
1146
|
+
components[entry.uAxis] = decoded.x.add(geometryNormal[entry.uAxis]);
|
|
1147
|
+
components[upAxis] = decoded.y.add(geometryNormal[upAxis]);
|
|
1148
|
+
return vec3(components.x, components.y, components.z);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export function toonLabDirectionalBeddingNormal(
|
|
1152
|
+
map,
|
|
1153
|
+
scale,
|
|
1154
|
+
blend,
|
|
1155
|
+
greenSign,
|
|
1156
|
+
coordinates,
|
|
1157
|
+
upAxis = 'y',
|
|
1158
|
+
) {
|
|
1159
|
+
const state = directionalBeddingState(scale, blend, coordinates, upAxis);
|
|
1160
|
+
const mapNode = texture(map);
|
|
1161
|
+
const candidates = state.descriptor.lateral.map((entry) => {
|
|
1162
|
+
const decoded = decodeToonLabNormal(mapNode.sample(vec2(
|
|
1163
|
+
state.projected[entry.uv[0]],
|
|
1164
|
+
state.projected[entry.uv[1]],
|
|
1165
|
+
)).rgb, greenSign);
|
|
1166
|
+
return directionalBeddingNormalCandidate(
|
|
1167
|
+
decoded,
|
|
1168
|
+
state.geometryNormal,
|
|
1169
|
+
entry,
|
|
1170
|
+
state.descriptor.upAxis,
|
|
1171
|
+
);
|
|
1172
|
+
});
|
|
1173
|
+
const sourceWorldNormal = safeNormalizeDirection(
|
|
1174
|
+
candidates[0].mul(state.weights[0]).add(candidates[1].mul(state.weights[1])),
|
|
1175
|
+
state.geometryNormal,
|
|
1176
|
+
);
|
|
1177
|
+
const worldNormal = safeNormalizeDirection(vec3(
|
|
1178
|
+
sourceWorldNormal.x,
|
|
1179
|
+
sourceWorldNormal.y,
|
|
1180
|
+
sourceWorldNormal.z.mul(coordinates.zSign),
|
|
1181
|
+
), state.geometryNormal);
|
|
1182
|
+
return worldNormalToTangent(worldNormal);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1030
1185
|
function toonLabNormalBlend(a, b) {
|
|
1031
1186
|
// S_Rock's Normal Blend node uses BlendMode.Default, not RNM.
|
|
1032
1187
|
return normalize(vec3(a.xy.add(b.xy), a.z.mul(b.z)));
|
|
@@ -1286,12 +1441,14 @@ export function createToonRockMaterial({
|
|
|
1286
1441
|
// The macro projection carries the authored silhouette-scale color breakup;
|
|
1287
1442
|
// a second metre-scale octave preserves readable rock surface detail close
|
|
1288
1443
|
// to the camera without changing that macro identity.
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1444
|
+
const nearDetailProfile = {
|
|
1445
|
+
...resolvedProfile,
|
|
1446
|
+
base: {
|
|
1447
|
+
...resolvedProfile.base,
|
|
1448
|
+
scale: resolvedProfile.base.nearDetailScale,
|
|
1449
|
+
},
|
|
1450
|
+
};
|
|
1451
|
+
const nearDetailSample = toonLabRockProjection(textures.rock, nearDetailProfile);
|
|
1295
1452
|
const nearDetailValue = dot(nearDetailSample, vec3(0.2126, 0.7152, 0.0722));
|
|
1296
1453
|
const nearDetailFade = clamp(
|
|
1297
1454
|
radialDistance.div(resolvedProfile.base.nearDetailDistance),
|
|
@@ -1320,16 +1477,24 @@ export function createToonRockMaterial({
|
|
|
1320
1477
|
);
|
|
1321
1478
|
|
|
1322
1479
|
if (resolvedProfile.base.striping.enabled) {
|
|
1323
|
-
const stripeProjected =
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1480
|
+
const stripeProjected = resolvedProfile.base.mode === 'directional-bedding'
|
|
1481
|
+
? toonLabDirectionalBeddingColor(
|
|
1482
|
+
textures.stripe,
|
|
1483
|
+
resolvedProfile.base.striping.scale,
|
|
1484
|
+
resolvedProfile.base.projectionContrast,
|
|
1485
|
+
resolvedProfile.coordinates,
|
|
1486
|
+
resolvedProfile.base.upAxis,
|
|
1487
|
+
)
|
|
1488
|
+
: toonLabSideProjection(
|
|
1489
|
+
textures.stripe,
|
|
1490
|
+
resolvedProfile.base.striping.scale,
|
|
1491
|
+
resolvedProfile.base.projectionContrast,
|
|
1492
|
+
{
|
|
1493
|
+
negativeScale: false,
|
|
1494
|
+
clampResult: false,
|
|
1495
|
+
coordinates: resolvedProfile.coordinates,
|
|
1496
|
+
},
|
|
1497
|
+
);
|
|
1333
1498
|
const stripeOpacity = clamp(
|
|
1334
1499
|
toonLabContrast(stripeProjected.r, resolvedProfile.base.striping.contrast),
|
|
1335
1500
|
0,
|
|
@@ -1811,16 +1976,28 @@ export function createToonRockMaterial({
|
|
|
1811
1976
|
: vec3(0, 0, 1);
|
|
1812
1977
|
let combinedNormal = stylizedNormal;
|
|
1813
1978
|
if (textures.rockNormal) {
|
|
1814
|
-
const crackNormal =
|
|
1815
|
-
|
|
1816
|
-
resolvedProfile.base.scale,
|
|
1817
|
-
resolvedProfile.base.projectionContrast,
|
|
1818
|
-
normalGreenSignForTexture(
|
|
1979
|
+
const crackNormal = resolvedProfile.base.mode === 'directional-bedding'
|
|
1980
|
+
? toonLabDirectionalBeddingNormal(
|
|
1819
1981
|
textures.rockNormal,
|
|
1820
|
-
resolvedProfile.
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1982
|
+
resolvedProfile.base.scale,
|
|
1983
|
+
resolvedProfile.base.projectionContrast,
|
|
1984
|
+
normalGreenSignForTexture(
|
|
1985
|
+
textures.rockNormal,
|
|
1986
|
+
resolvedProfile.normals.normalGreenSign,
|
|
1987
|
+
),
|
|
1988
|
+
resolvedProfile.coordinates,
|
|
1989
|
+
resolvedProfile.base.upAxis,
|
|
1990
|
+
)
|
|
1991
|
+
: toonLabTriplanarNormal(
|
|
1992
|
+
textures.rockNormal,
|
|
1993
|
+
resolvedProfile.base.scale,
|
|
1994
|
+
resolvedProfile.base.projectionContrast,
|
|
1995
|
+
normalGreenSignForTexture(
|
|
1996
|
+
textures.rockNormal,
|
|
1997
|
+
resolvedProfile.normals.normalGreenSign,
|
|
1998
|
+
),
|
|
1999
|
+
resolvedProfile.coordinates,
|
|
2000
|
+
);
|
|
1824
2001
|
const normalFade = clamp(
|
|
1825
2002
|
radialDistance.div(Math.max(
|
|
1826
2003
|
resolvedProfile.normals.distance * authoredDistanceScale,
|
|
@@ -226,6 +226,8 @@ export function rockShaderSettingsToProfile(input = {}) {
|
|
|
226
226
|
|
|
227
227
|
return normalizeToonLabRockProfile({
|
|
228
228
|
base: {
|
|
229
|
+
mode: projection.mode,
|
|
230
|
+
upAxis: projection.upAxis,
|
|
229
231
|
scale: projection.scale,
|
|
230
232
|
projectionContrast: projection.projectionContrast,
|
|
231
233
|
sideOnly: projection.sideOnly,
|
|
@@ -373,6 +375,11 @@ function createMaterialForSource({
|
|
|
373
375
|
materialProfile.shoreline = structuredClone(profile.shoreline);
|
|
374
376
|
materialProfile.base = {
|
|
375
377
|
...materialProfile.base,
|
|
378
|
+
// Projection choice belongs to the edited C8 surface package/runtime
|
|
379
|
+
// binding, not to a shared semantic material set. These strings select
|
|
380
|
+
// a CPU-authored TSL graph; they are deliberately not shader uniforms.
|
|
381
|
+
mode: profile.base.mode,
|
|
382
|
+
upAxis: profile.base.upAxis,
|
|
376
383
|
closeTintDistance: profile.base.closeTintDistance,
|
|
377
384
|
distantTint: profile.base.distantTint,
|
|
378
385
|
distantTintMix: profile.base.distantTintMix,
|
|
@@ -529,6 +536,12 @@ function createMaterialForSource({
|
|
|
529
536
|
},
|
|
530
537
|
sourceTextureCount: rockMaterial.userData.toonlabSourceTextureIds.length,
|
|
531
538
|
};
|
|
539
|
+
rockMaterial.userData.toonlabRockProjectionContract = {
|
|
540
|
+
mapRoles: ['BaseColor', 'NearDetail', 'Smoothness', 'NormalGL', 'AO'],
|
|
541
|
+
mode: materialProfile.base.mode,
|
|
542
|
+
sideOnly: materialProfile.base.sideOnly,
|
|
543
|
+
upAxis: materialProfile.base.upAxis,
|
|
544
|
+
};
|
|
532
545
|
if (semantic) {
|
|
533
546
|
rockMaterial.userData.toonLabRockTextureComposition.materialSetId = semantic.materialSetId;
|
|
534
547
|
rockMaterial.userData.toonLabRockTextureComposition.surfaceState = surfaceState;
|
|
@@ -560,8 +573,11 @@ function createMaterialForSource({
|
|
|
560
573
|
: materialProfile.layers?.[activeLayer]?.roughness ?? null,
|
|
561
574
|
};
|
|
562
575
|
rockMaterial.userData.toonlabRockProjectionContract = {
|
|
576
|
+
...rockMaterial.userData.toonlabRockProjectionContract,
|
|
563
577
|
normalBlend: 'whiteout-world-to-tangent',
|
|
564
|
-
primary: '
|
|
578
|
+
primary: materialProfile.base.mode === 'directional-bedding'
|
|
579
|
+
? 'world-space-directional-bedding'
|
|
580
|
+
: 'world-space-triplanar',
|
|
565
581
|
scaleCompensation: 'absolute-world-position',
|
|
566
582
|
texelDensityInvariantUnderNonUniformScale: true,
|
|
567
583
|
};
|