@call-me-sensei/toonlab 0.5.0 → 0.5.1
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 +3 -3
- package/README.md +1 -1
- package/agents/PROMPTS.md +1 -1
- package/package.json +1 -1
- package/src/shaders-tsl/character/characterMaterial.js +4 -2
- package/src/toon/autoRoles.js +221 -34
- package/src/toon/characterParameters.js +3 -0
- package/src/toon/settings/outlineSettings.js +9 -0
- package/src/toon/toonMaterialAdapter.js +5 -0
- package/src/version.js +1 -1
- package/types/toon/autoRoles.d.ts +32 -14
- package/types/toon/settings/outlineSettings.d.ts +6 -0
- package/types/toon/toonMaterialAdapter.d.ts +20 -27
- package/types/version.d.ts +1 -1
package/NPM-LIBRARY.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# ToonLab npm library guide
|
|
2
2
|
|
|
3
3
|
This guide is included in `@call-me-sensei/toonlab`. It is the package-first
|
|
4
|
-
reference for version 0.5.
|
|
4
|
+
reference for version 0.5.1 and does not require access to the source
|
|
5
5
|
repository.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
@@ -143,7 +143,7 @@ surface detail, not displacement that replaces a geometric high-to-low bake.
|
|
|
143
143
|
|
|
144
144
|
## Load Nature Reference Rocks
|
|
145
145
|
|
|
146
|
-
Version 0.5.
|
|
146
|
+
Version 0.5.1 understands both the original `rock-0001`–`rock-0480`
|
|
147
147
|
identities and the Nature Reference Rocks catalog. Acquiring an asset
|
|
148
148
|
directly returns its realistic PBR GLB. The one-call placement path additionally
|
|
149
149
|
loads that asset's reviewed geology maps and `call_me_sensei` material settings
|
|
@@ -229,7 +229,7 @@ it uses the disk workspace passed through `--workspace`; no account is needed.
|
|
|
229
229
|
"command": "npx",
|
|
230
230
|
"args": [
|
|
231
231
|
"-y",
|
|
232
|
-
"--package=@call-me-sensei/toonlab@0.5.
|
|
232
|
+
"--package=@call-me-sensei/toonlab@0.5.1",
|
|
233
233
|
"toonlab-mcp",
|
|
234
234
|
"--workspace",
|
|
235
235
|
"/absolute/path/to/your-game/.toonlab"
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Use ToonLab as a focused runtime library (`@call-me-sensei/toonlab` on npm), as
|
|
|
19
19
|
an asset/preset authoring workspace, or as an asset-discovery service—not as a
|
|
20
20
|
one-shot world generator.
|
|
21
21
|
|
|
22
|
-
## Versioned expectation: 0.5.
|
|
22
|
+
## Versioned expectation: 0.5.1
|
|
23
23
|
|
|
24
24
|
For a correctly constructed and semantically labeled scene, one strict Call Me
|
|
25
25
|
Sensei bundle application is expected to establish the supported visual
|
package/agents/PROMPTS.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@call-me-sensei/toonlab",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Anime-style game toolkit for Three.js with cel shading, vegetation, rocks, water, sky, clouds, procedural textures, and portable Lab artifacts",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Hyperbond Studio PTE. LTD. (Call Me Sensei)",
|
|
@@ -561,7 +561,9 @@ function vertexBlend(u, features) {
|
|
|
561
561
|
|
|
562
562
|
// View-space displacement of the hull: outward along the (baked smooth)
|
|
563
563
|
// geometry normal by the role's width, corrected to a constant on-screen
|
|
564
|
-
// width, and pushed back along the view ray
|
|
564
|
+
// width, and pushed back along the view ray by the role's depth push (faces:
|
|
565
|
+
// faceDepthPush by face weight), so inner edges of layered parts fail the
|
|
566
|
+
// depth test against the surface behind them and only silhouettes ink.
|
|
565
567
|
function outlineViewOffset(u, features) {
|
|
566
568
|
return (positionViewBase) => {
|
|
567
569
|
const blend = vertexBlend(u, features);
|
|
@@ -586,7 +588,7 @@ function outlineViewOffset(u, features) {
|
|
|
586
588
|
}
|
|
587
589
|
width = min(width, u.outlineMaxWidth);
|
|
588
590
|
const away = select(orthographic, vec3(0, 0, -1), normalize(positionViewBase));
|
|
589
|
-
return normalView.mul(width).add(away.mul(u.outlineFaceDepthPush.mul(faceWeight)));
|
|
591
|
+
return normalView.mul(width).add(away.mul(blend('outlineDepthPush').add(u.outlineFaceDepthPush.mul(faceWeight))));
|
|
590
592
|
};
|
|
591
593
|
}
|
|
592
594
|
|
package/src/toon/autoRoles.js
CHANGED
|
@@ -19,7 +19,15 @@ import { rasterizeTriangle, readTexturePixels, sampleTexturePixel } from './text
|
|
|
19
19
|
// 2. The model's own skin tone, sampled from the cheek/chin band of the
|
|
20
20
|
// front of the head — a region that is skin on essentially every
|
|
21
21
|
// character. The band must itself look like skin (skinEvidence.js), so a
|
|
22
|
-
// helmet visor or a robot face is not taken as one
|
|
22
|
+
// helmet visor or a robot face is not taken as one; glasses or a lock of
|
|
23
|
+
// hair across the band may outnumber the skin in it, so a band that is
|
|
24
|
+
// mostly skin-coloured texels by a clear share still counts. Brown,
|
|
25
|
+
// auburn and blond hair is skin-coloured by hue too, and hair framing a
|
|
26
|
+
// chibi face fills much of the band: when the band's skin-coloured texels
|
|
27
|
+
// form two clearly different lightness groups and the crown's lightness
|
|
28
|
+
// matches the darker one, that group is hair and the lighter one is the
|
|
29
|
+
// skin, and from then on only texels on the skin's side of the lightness
|
|
30
|
+
// boundary count as skin-toned. Hair as light as the skin is not split.
|
|
23
31
|
// 3. Face = the front face zone of the head. Below the brow line everything
|
|
24
32
|
// is face (eyes, brows and mouth belong to it); above it only texels in
|
|
25
33
|
// the model's skin tone (so bangs over the forehead are not face).
|
|
@@ -27,7 +35,8 @@ import { rasterizeTriangle, readTexturePixels, sampleTexturePixel } from './text
|
|
|
27
35
|
// = body texels in the face's skin tone.
|
|
28
36
|
//
|
|
29
37
|
// The result is a normalized Uint8 vec4 `toonRoleWeights` (R skin, G face,
|
|
30
|
-
// B hair
|
|
38
|
+
// B hair; a partition — they sum to at most 1, the rest is the material's own
|
|
39
|
+
// role) per vertex, consumed by the toon shader, and an estimated head frame
|
|
31
40
|
// on the root for face lighting on unrigged models. When the head is low-poly
|
|
32
41
|
// (a face of a few large polygons, where per-vertex weights smear the
|
|
33
42
|
// face/hair boundary across whole triangles), the same classification is also
|
|
@@ -62,6 +71,19 @@ function colorSignature(rgba, target = [0, 0, 0]) {
|
|
|
62
71
|
return target;
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
// Oklab lightness (0 black – 1 white) of an sRGB texel. Perceptual, unlike the
|
|
75
|
+
// down-weighted log-luminance above: it is what separates skin from hair of
|
|
76
|
+
// the same warm hue.
|
|
77
|
+
function oklabLightness(rgba) {
|
|
78
|
+
const r = srgbToLinear(rgba[0]);
|
|
79
|
+
const g = srgbToLinear(rgba[1]);
|
|
80
|
+
const b = srgbToLinear(rgba[2]);
|
|
81
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
82
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
83
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
84
|
+
return 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
function signatureDistance(a, b) {
|
|
66
88
|
const dr = a[0] - b[0];
|
|
67
89
|
const dg = a[1] - b[1];
|
|
@@ -85,6 +107,79 @@ function collectTargets(root, isEligible) {
|
|
|
85
107
|
return targets;
|
|
86
108
|
}
|
|
87
109
|
|
|
110
|
+
// Texture (or material) colour at a vertex, 0-255 RGBA into `target`.
|
|
111
|
+
function sampleVertexColor(mesh, i, target) {
|
|
112
|
+
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
|
113
|
+
const texture = material?.map;
|
|
114
|
+
const uv = mesh.geometry.attributes.uv;
|
|
115
|
+
const pixels = texture ? readTexturePixels(texture, { maxSize: 1024 }) : null;
|
|
116
|
+
if (!pixels || !uv) {
|
|
117
|
+
const color = material?.color ?? new THREE.Color(1, 1, 1);
|
|
118
|
+
target[0] = color.r * 255; target[1] = color.g * 255; target[2] = color.b * 255; target[3] = 255;
|
|
119
|
+
return target;
|
|
120
|
+
}
|
|
121
|
+
return sampleTexturePixel(pixels, texture, uv.getX(i), uv.getY(i), target);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function updateMapMatrices(meshes) {
|
|
125
|
+
for (const mesh of meshes) {
|
|
126
|
+
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
|
127
|
+
if (material?.map?.matrixAutoUpdate) material.map.updateMatrix();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Rest-pose facing. Every classification here works in the character frame
|
|
132
|
+
// (facing +Z), which a model states with `root.userData.toonForward`; without
|
|
133
|
+
// it +Z is assumed. A rig whose rest pose faces elsewhere while its animations
|
|
134
|
+
// turn it forward (the Sensei Run chibis face -X at rest) was analysed from
|
|
135
|
+
// the side of the head: the face put on one cheek and the hair over the eyes.
|
|
136
|
+
// The face is where the head's bright skin-coloured surface points: measured
|
|
137
|
+
// on five chibis (2026-10-13), its mean horizontal normal is 0.21-0.41 long
|
|
138
|
+
// and within 9 degrees of an axis; brown hair (skin-coloured by hue) is
|
|
139
|
+
// excluded by lightness. Rigged models only (the head comes from the skin
|
|
140
|
+
// weights); bald or masked heads give no clear direction and keep +Z. Light
|
|
141
|
+
// warm hair is not excluded (Yuxi's ash-grey hair, 2026-10-16, pointed it at
|
|
142
|
+
// -Z), so inferAutoRoleWeights consults it only after the +Z analysis fails.
|
|
143
|
+
const FORWARD_MIN_SAMPLES = 64;
|
|
144
|
+
const FORWARD_MIN_STRENGTH = 0.12;
|
|
145
|
+
const FORWARD_SKIN_MIN_LIGHTNESS = 0.6;
|
|
146
|
+
|
|
147
|
+
function estimateRestForward(root, targets, headBones) {
|
|
148
|
+
if (headBones.size === 0) return null;
|
|
149
|
+
updateMapMatrices(targets);
|
|
150
|
+
const rootInverse = new THREE.Matrix4().copy(root.matrixWorld).invert();
|
|
151
|
+
const normal = new THREE.Vector3();
|
|
152
|
+
const texel = [0, 0, 0, 255];
|
|
153
|
+
let sumX = 0;
|
|
154
|
+
let sumZ = 0;
|
|
155
|
+
let count = 0;
|
|
156
|
+
for (const mesh of targets) {
|
|
157
|
+
const headness = skinnedHeadness(mesh, headBones);
|
|
158
|
+
const normals = mesh.geometry.attributes.normal;
|
|
159
|
+
if (!headness || !normals) continue;
|
|
160
|
+
const normalMatrix = new THREE.Matrix3().getNormalMatrix(new THREE.Matrix4().multiplyMatrices(rootInverse, mesh.matrixWorld));
|
|
161
|
+
const stride = Math.max(1, Math.floor(normals.count / 20000));
|
|
162
|
+
for (let i = 0; i < normals.count; i += stride) {
|
|
163
|
+
if (headness[i] < 0.9) continue;
|
|
164
|
+
sampleVertexColor(mesh, i, texel);
|
|
165
|
+
if (!isSkinLikeColor(texel[0] / 255, texel[1] / 255, texel[2] / 255)) continue;
|
|
166
|
+
if (oklabLightness(texel) < FORWARD_SKIN_MIN_LIGHTNESS) continue;
|
|
167
|
+
normal.fromBufferAttribute(normals, i).applyMatrix3(normalMatrix).normalize();
|
|
168
|
+
sumX += normal.x;
|
|
169
|
+
sumZ += normal.z;
|
|
170
|
+
count += 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (count < FORWARD_MIN_SAMPLES) return null;
|
|
174
|
+
const x = sumX / count;
|
|
175
|
+
const z = sumZ / count;
|
|
176
|
+
const strength = Math.hypot(x, z);
|
|
177
|
+
if (strength < FORWARD_MIN_STRENGTH) return null;
|
|
178
|
+
// Rigs are authored axis-aligned: the nearest horizontal axis.
|
|
179
|
+
const forward = Math.abs(x) > Math.abs(z) ? [Math.sign(x), 0, 0] : [0, 0, Math.sign(z)];
|
|
180
|
+
return { forward, samples: count, strength };
|
|
181
|
+
}
|
|
182
|
+
|
|
88
183
|
function headBoneSet(headBone) {
|
|
89
184
|
const bones = new Set();
|
|
90
185
|
headBone?.traverse((node) => {
|
|
@@ -136,7 +231,23 @@ const FOOTPRINT_PERCENTILE = 0.8;
|
|
|
136
231
|
// least this share of it (large painted features — a chibi nose and lips —
|
|
137
232
|
// take the rest).
|
|
138
233
|
const REFERENCE_MIN_SKIN_FRACTION = 0.3;
|
|
234
|
+
// When the band's median colour is not skin (glasses frames and lenses, or a
|
|
235
|
+
// lock of hair, cover much of it), a larger share of skin-coloured texels is
|
|
236
|
+
// required instead (Keisuke, glasses: 42 %; Akika, hair over the cheeks: 38 %).
|
|
237
|
+
const REFERENCE_MIN_SKIN_FRACTION_MIXED = 0.35;
|
|
139
238
|
const SATURATED_REFERENCE_CHROMA = 0.2;
|
|
239
|
+
// Skin-coloured hair in the cheek band: its skin-coloured texels split into
|
|
240
|
+
// two lightness groups (1-D two-means on Oklab lightness) at least this far
|
|
241
|
+
// apart, the smaller holding at least SKIN_SPLIT_MIN_SHARE of them. Measured
|
|
242
|
+
// on six chibis with brown hair (2026-10-13): group centres skin 0.72–0.84,
|
|
243
|
+
// hair 0.32–0.46 (black hair is not skin-coloured and never splits). The
|
|
244
|
+
// boundary is softened by SKIN_SPLIT_SOFTNESS either side.
|
|
245
|
+
const SKIN_SPLIT_MIN_GAP = 0.2;
|
|
246
|
+
const SKIN_SPLIT_MIN_SHARE = 0.1;
|
|
247
|
+
const SKIN_SPLIT_SOFTNESS = 0.04;
|
|
248
|
+
// Crown: head texels this far above the head's centre (fraction of its height
|
|
249
|
+
// radius) give the hair's lightness.
|
|
250
|
+
const CROWN_Y = 0.7;
|
|
140
251
|
|
|
141
252
|
function extendHeadAboveNeck(meshes, headBone, rootInverse) {
|
|
142
253
|
const neckBone = headBone?.parent?.isBone ? headBone.parent : null;
|
|
@@ -170,6 +281,48 @@ function percentileOf(sorted, fraction) {
|
|
|
170
281
|
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * fraction)))];
|
|
171
282
|
}
|
|
172
283
|
|
|
284
|
+
// Two lightness groups among the band's skin-coloured texels — skin and darker
|
|
285
|
+
// hair of the same warm hue — or null when they form one group. The darker
|
|
286
|
+
// group is the hair only when the crown's median lightness is nearer to it.
|
|
287
|
+
// Hair as light as the skin or lighter (ash, silver, platinum) is never split
|
|
288
|
+
// off: its crown matches the lighter group, which is then the skin itself
|
|
289
|
+
// (Yuxi, 2026-10-16: crown and cheeks both about 0.8, the darker group her
|
|
290
|
+
// lips and lashes), so such models keep the unsplit skin reference. (A bald
|
|
291
|
+
// head with a brown beard would be read the wrong way round; name its face
|
|
292
|
+
// material to skip automatic roles.)
|
|
293
|
+
function splitSkinFromHair(lightness, crownLightness) {
|
|
294
|
+
if (lightness.length < 32 || crownLightness.length < 16) return null;
|
|
295
|
+
const sorted = [...lightness].sort((a, b) => a - b);
|
|
296
|
+
let low = percentileOf(sorted, 0.2);
|
|
297
|
+
let high = percentileOf(sorted, 0.8);
|
|
298
|
+
let lowCount = 0;
|
|
299
|
+
for (let iteration = 0; iteration < 16; iteration += 1) {
|
|
300
|
+
const boundary = (low + high) / 2;
|
|
301
|
+
let lowSum = 0;
|
|
302
|
+
let highSum = 0;
|
|
303
|
+
lowCount = 0;
|
|
304
|
+
for (const value of sorted) {
|
|
305
|
+
if (value < boundary) {
|
|
306
|
+
lowSum += value;
|
|
307
|
+
lowCount += 1;
|
|
308
|
+
} else {
|
|
309
|
+
highSum += value;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (lowCount === 0 || lowCount === sorted.length) return null;
|
|
313
|
+
low = lowSum / lowCount;
|
|
314
|
+
high = highSum / (sorted.length - lowCount);
|
|
315
|
+
}
|
|
316
|
+
const smallerShare = Math.min(lowCount, sorted.length - lowCount) / sorted.length;
|
|
317
|
+
if (high - low < SKIN_SPLIT_MIN_GAP || smallerShare < SKIN_SPLIT_MIN_SHARE) return null;
|
|
318
|
+
const crown = median(crownLightness);
|
|
319
|
+
if (Math.abs(crown - low) > Math.abs(crown - high)) return null;
|
|
320
|
+
const boundary = (low + high) / 2;
|
|
321
|
+
// 1 on the skin's (lighter) side of the boundary.
|
|
322
|
+
const weight = (value) => smoothstep(boundary - SKIN_SPLIT_SOFTNESS, boundary + SKIN_SPLIT_SOFTNESS, value);
|
|
323
|
+
return { boundary, groups: [low, high], hair: 'darker', isSkinSide: (value) => weight(value) >= 0.5, weight };
|
|
324
|
+
}
|
|
325
|
+
|
|
173
326
|
// Centre and radii (root space, x/y) of the visible face: head vertices that
|
|
174
327
|
// face forward, sit on the front half of the head and match the skin tone.
|
|
175
328
|
function measureFaceFrame(meshes, headnessAt, isSkinToned, center, radiusX) {
|
|
@@ -320,11 +473,24 @@ function findNeckHeight(slices, minY, sliceHeight) {
|
|
|
320
473
|
return minY + (neck + 0.5) * sliceHeight;
|
|
321
474
|
}
|
|
322
475
|
|
|
476
|
+
// Analysis failures that mean "no skin where the face should be", after which
|
|
477
|
+
// an undeclared rest-pose facing is estimated and the analysis retried.
|
|
478
|
+
const RETRY_WITH_INFERRED_FORWARD = new Set(['no-skin-reference', 'reference-not-skin']);
|
|
479
|
+
|
|
323
480
|
/**
|
|
324
481
|
* Infers per-vertex role weights for the eligible meshes under `root`.
|
|
325
482
|
* `isEligible(material)` selects materials whose roles are unknown.
|
|
326
483
|
* Returns a report; `applied` is false (with a `reason`) when evidence is
|
|
327
484
|
* insufficient, in which case nothing is modified.
|
|
485
|
+
*
|
|
486
|
+
* The analysis runs in the declared character frame (`root.userData.toonForward`,
|
|
487
|
+
* +Z when none is declared). Only when a model declares nothing and its +Z
|
|
488
|
+
* analysis finds no skin in the cheek band is its rest-pose facing estimated
|
|
489
|
+
* (estimateRestForward) and the analysis retried from that side. The estimate
|
|
490
|
+
* is a fallback, never an override: light warm hair (ash, silver, blond) is
|
|
491
|
+
* skin-coloured by hue and can outweigh the face in it. The inferred axis is
|
|
492
|
+
* kept, and written to the root so later bakes and head tracking agree, only
|
|
493
|
+
* when the retry succeeds.
|
|
328
494
|
*/
|
|
329
495
|
export function inferAutoRoleWeights(root, {
|
|
330
496
|
headBone = null,
|
|
@@ -335,10 +501,33 @@ export function inferAutoRoleWeights(root, {
|
|
|
335
501
|
if (targets.length === 0) return { applied: false, reason: 'no-eligible-meshes' };
|
|
336
502
|
|
|
337
503
|
root.updateMatrixWorld(true);
|
|
504
|
+
const headBones = headBoneSet(headBone);
|
|
505
|
+
const analyse = (forwardInferred) => analyseRoles(root, targets, headBone, headBones, skinTolerance, forwardInferred);
|
|
506
|
+
const result = analyse(null);
|
|
507
|
+
if (result.applied || !RETRY_WITH_INFERRED_FORWARD.has(result.reason)) return result;
|
|
508
|
+
if (Array.isArray(root.userData?.toonForward)) return result;
|
|
509
|
+
|
|
510
|
+
const estimate = estimateRestForward(root, targets, headBones);
|
|
511
|
+
if (!estimate || estimate.forward[2] === 1) return result;
|
|
512
|
+
const previousForward = root.userData.toonForward;
|
|
513
|
+
root.userData.toonForward = estimate.forward;
|
|
514
|
+
const retried = analyse(estimate);
|
|
515
|
+
if (retried.applied) return retried;
|
|
516
|
+
if (previousForward === undefined) delete root.userData.toonForward;
|
|
517
|
+
else root.userData.toonForward = previousForward;
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* One analysis in the root's current character frame. Returns before
|
|
523
|
+
* modifying anything when the evidence is insufficient.
|
|
524
|
+
* @param {THREE.Mesh[]} targets
|
|
525
|
+
* @param {{ forward: number[], samples: number, strength: number } | null} forwardInferred
|
|
526
|
+
*/
|
|
527
|
+
function analyseRoles(root, targets, headBone, headBones, skinTolerance, forwardInferred) {
|
|
338
528
|
// The character frame (facing +Z), not the root's raw frame: a VRM 0.x
|
|
339
529
|
// root is turned 180° by its importer.
|
|
340
530
|
const rootInverse = characterFrameInverse(root);
|
|
341
|
-
const headBones = headBoneSet(headBone);
|
|
342
531
|
|
|
343
532
|
// Gather every eligible vertex in root space once.
|
|
344
533
|
const meshes = targets.map((mesh) => {
|
|
@@ -422,34 +611,22 @@ export function inferAutoRoleWeights(root, {
|
|
|
422
611
|
const radiusY = Math.max((headBox.max.y - headBox.min.y) / 2, 1e-4);
|
|
423
612
|
|
|
424
613
|
// Skin reference from the cheek/chin band on the front of the head.
|
|
425
|
-
const sampleColor =
|
|
426
|
-
|
|
427
|
-
const texture = material?.map;
|
|
428
|
-
const uv = mesh.geometry.attributes.uv;
|
|
429
|
-
const pixels = texture ? readTexturePixels(texture, { maxSize: 1024 }) : null;
|
|
430
|
-
if (!pixels || !uv) {
|
|
431
|
-
const color = material?.color ?? new THREE.Color(1, 1, 1);
|
|
432
|
-
target[0] = color.r * 255; target[1] = color.g * 255; target[2] = color.b * 255; target[3] = 255;
|
|
433
|
-
return target;
|
|
434
|
-
}
|
|
435
|
-
return sampleTexturePixel(pixels, texture, uv.getX(i), uv.getY(i), target);
|
|
436
|
-
};
|
|
437
|
-
for (const { mesh } of meshes) {
|
|
438
|
-
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
|
439
|
-
if (material?.map?.matrixAutoUpdate) material.map.updateMatrix();
|
|
440
|
-
}
|
|
614
|
+
const sampleColor = sampleVertexColor;
|
|
615
|
+
updateMapMatrices(targets);
|
|
441
616
|
|
|
442
617
|
const texel = [0, 0, 0, 255];
|
|
443
618
|
const signature = [0, 0, 0];
|
|
444
|
-
|
|
445
|
-
|
|
619
|
+
// Skin-coloured band texels: signature and Oklab lightness each.
|
|
620
|
+
const bandSkin = [];
|
|
446
621
|
const referenceRgb = [[], [], []];
|
|
622
|
+
const crownLightness = [];
|
|
447
623
|
for (const entry of meshes) {
|
|
448
624
|
const stride = Math.max(1, Math.floor(entry.points.length / 3 / 20000));
|
|
449
625
|
for (let i = 0; i < entry.points.length / 3; i += stride) {
|
|
450
626
|
if (headnessAt(entry, i) < 0.9) continue;
|
|
451
627
|
const x = (entry.points[i * 3] - center.x) / radiusX;
|
|
452
628
|
const y = (entry.points[i * 3 + 1] - center.y) / radiusY;
|
|
629
|
+
if (y > CROWN_Y) crownLightness.push(oklabLightness(sampleColor(entry.mesh, i, texel)));
|
|
453
630
|
const facing = entry.normals[i * 3 + 2];
|
|
454
631
|
if (facing < 0.55 || Math.abs(x) > 0.45 || y > -0.1 || y < -0.65) continue;
|
|
455
632
|
colorSignature(sampleColor(entry.mesh, i, texel), signature);
|
|
@@ -458,20 +635,22 @@ export function inferAutoRoleWeights(root, {
|
|
|
458
635
|
// a big red nose, dark lips and eyes in this zone otherwise pulled it
|
|
459
636
|
// off the skin, leaving the forehead and outer cheeks part hair.
|
|
460
637
|
if (!isSkinLikeColor(texel[0] / 255, texel[1] / 255, texel[2] / 255)) continue;
|
|
461
|
-
|
|
462
|
-
reference[0].push(signature[0]);
|
|
463
|
-
reference[1].push(signature[1]);
|
|
464
|
-
reference[2].push(signature[2]);
|
|
638
|
+
bandSkin.push({ signature: [signature[0], signature[1], signature[2]], lightness: oklabLightness(texel) });
|
|
465
639
|
}
|
|
466
640
|
}
|
|
467
641
|
const sampled = referenceRgb[0].length;
|
|
468
642
|
if (sampled < 16) return { applied: false, reason: 'no-skin-reference' };
|
|
469
643
|
const medianRgb = referenceRgb.map(median);
|
|
470
644
|
const medianIsSkin = isSkinLikeColor(medianRgb[0] / 255, medianRgb[1] / 255, medianRgb[2] / 255);
|
|
471
|
-
|
|
472
|
-
|
|
645
|
+
const minSkinFraction = medianIsSkin ? REFERENCE_MIN_SKIN_FRACTION : REFERENCE_MIN_SKIN_FRACTION_MIXED;
|
|
646
|
+
if (bandSkin.length < 16 || bandSkin.length < sampled * minSkinFraction) {
|
|
647
|
+
return { applied: false, medianRgb, reason: 'reference-not-skin', referenceSkinFraction: bandSkin.length / sampled };
|
|
473
648
|
}
|
|
474
|
-
const
|
|
649
|
+
const skinSplit = splitSkinFromHair(bandSkin.map((sample) => sample.lightness), crownLightness);
|
|
650
|
+
const skinSamples = skinSplit ? bandSkin.filter((sample) => skinSplit.isSkinSide(sample.lightness)) : bandSkin;
|
|
651
|
+
const skinReference = [0, 1, 2].map((k) => median(skinSamples.map((sample) => sample.signature[k])));
|
|
652
|
+
// 1 on the skin's side of a lightness split (always 1 without one).
|
|
653
|
+
const skinSideAt = (rgba) => (skinSplit ? skinSplit.weight(oklabLightness(rgba)) : 1);
|
|
475
654
|
// Linear chromaticity spreads saturated colours apart: painted shading on an
|
|
476
655
|
// orange-brown skin moves further in it than on pale skin. The tolerance
|
|
477
656
|
// grows with the reference's own distance from neutral (pale and typical
|
|
@@ -485,7 +664,7 @@ export function inferAutoRoleWeights(root, {
|
|
|
485
664
|
// so the scalp of a bald head is not all face.
|
|
486
665
|
const faceFrame = measureFaceFrame(meshes, headnessAt, (entry, i) => {
|
|
487
666
|
colorSignature(sampleColor(entry.mesh, i, texel), signature);
|
|
488
|
-
return signatureDistance(signature, skinReference) <= tolerance;
|
|
667
|
+
return signatureDistance(signature, skinReference) <= tolerance && skinSideAt(texel) >= 0.5;
|
|
489
668
|
}, center, radiusX);
|
|
490
669
|
// The upper half fades out over the forehead rather than at a hard edge:
|
|
491
670
|
// face shading follows the face map (light azimuth only) and the scalp
|
|
@@ -507,7 +686,7 @@ export function inferAutoRoleWeights(root, {
|
|
|
507
686
|
const roles = [0, 0, 0];
|
|
508
687
|
const classify = (px, py, pz, facing, headness, rgba) => {
|
|
509
688
|
colorSignature(rgba, signature);
|
|
510
|
-
const skinLike = 1 - smoothstep(tolerance * 0.6, tolerance, signatureDistance(signature, skinReference));
|
|
689
|
+
const skinLike = (1 - smoothstep(tolerance * 0.6, tolerance, signatureDistance(signature, skinReference))) * skinSideAt(rgba);
|
|
511
690
|
const z = pz - center.z;
|
|
512
691
|
// On the front of the face either by normal or by position: recessed eye
|
|
513
692
|
// sockets, nose sides and lips face sideways, but sit well in front of the
|
|
@@ -515,10 +694,14 @@ export function inferAutoRoleWeights(root, {
|
|
|
515
694
|
const front = Math.max(smoothstep(0.05, 0.35, facing), smoothstep(0.35, 0.6, z / radiusX));
|
|
516
695
|
const frontZone = headness * ovalAt(px, py) * front * (z > -0.1 * radiusX ? 1 : 0);
|
|
517
696
|
const face = frontZone * Math.max(lowerFaceAt(py), skinLike);
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
697
|
+
// The weights partition the surface: the shader blends each role's full
|
|
698
|
+
// parameters by its weight and gives the material's own role the
|
|
699
|
+
// remainder (characterMaterial.js createBlend). Skin is the skin-coloured
|
|
700
|
+
// share that is not face, so a skin-coloured cheek is fully face plus skin
|
|
701
|
+
// with no cloth remainder, and a face texel is not also counted as skin
|
|
702
|
+
// (that summed the skin and face terminators, tones and outline widths,
|
|
703
|
+
// washing faces out).
|
|
704
|
+
roles[0] = (1 - face) * skinLike;
|
|
522
705
|
roles[1] = face;
|
|
523
706
|
roles[2] = headness * (1 - face) * (1 - skinLike);
|
|
524
707
|
return roles;
|
|
@@ -581,9 +764,13 @@ export function inferAutoRoleWeights(root, {
|
|
|
581
764
|
headVertexCount: headCount,
|
|
582
765
|
meshes: meshes.length,
|
|
583
766
|
faceFrame,
|
|
767
|
+
forwardInferred,
|
|
768
|
+
head: { center: center.toArray(), radiusX, radiusY },
|
|
584
769
|
maskedMeshes,
|
|
585
770
|
method: rigged ? 'skin-weights' : 'silhouette',
|
|
771
|
+
referenceSkinFraction: bandSkin.length / sampled,
|
|
586
772
|
skinReference,
|
|
773
|
+
skinSplit: skinSplit ? { boundary: skinSplit.boundary, groups: skinSplit.groups, hair: skinSplit.hair } : null,
|
|
587
774
|
skinTolerance: tolerance,
|
|
588
775
|
};
|
|
589
776
|
}
|
|
@@ -42,6 +42,7 @@ export const ROLE_PARAMETER_NAMES = Object.freeze([
|
|
|
42
42
|
'specularThreshold',
|
|
43
43
|
'specularSoftness',
|
|
44
44
|
'outlineWidth',
|
|
45
|
+
'outlineDepthPush',
|
|
45
46
|
'ink',
|
|
46
47
|
'inkSaturation',
|
|
47
48
|
'outlineLightingMix',
|
|
@@ -101,6 +102,8 @@ function roleParameters(settings, role, { hasSpecularMask = false, paintedEyeHig
|
|
|
101
102
|
outlineMaxBrightness: Math.max(lighting.min, lighting.max),
|
|
102
103
|
outlineMinBrightness: lighting.min,
|
|
103
104
|
outlineWidth: outline.enabled ? outline.width[role] : 0,
|
|
105
|
+
// Faces (and eyes) use faceDepthPush, applied by face weight in the hull shader.
|
|
106
|
+
outlineDepthPush: inkRole === 'face' ? 0 : outline.depthPush[inkRole] ?? 0,
|
|
104
107
|
rimIntensity: rim.enabled ? rim.intensity[rimRole] : 0,
|
|
105
108
|
rimSilhouette: role === 'hair' ? rim.silhouette.hair : role === 'eye' ? 0 : rim.silhouette.body,
|
|
106
109
|
sceneShadowStrength: sceneShadow,
|
|
@@ -56,6 +56,15 @@ export const OUTLINE_FIELDS = Object.freeze({
|
|
|
56
56
|
metal: lightingRole(0.2, 'metal'),
|
|
57
57
|
}),
|
|
58
58
|
faceDepthPush: numberField(0.02, 0, 0.2, 'Push face hull lines back in depth (metres) so they show only on the silhouette, not across nose and eyelids.'),
|
|
59
|
+
// The same push for the other roles. Layered parts (a jacket over a shirt, sleeves, separate hair locks) each
|
|
60
|
+
// carry their own hull, so without it every inner edge inks a full-width line across the body; pushed back,
|
|
61
|
+
// a part's hull only shows where the part stands clear of what is behind it (its silhouette).
|
|
62
|
+
depthPush: Object.freeze({
|
|
63
|
+
cloth: numberField(0, 0, 0.2, 'Push cloth hull lines back in depth (metres): inner edges of layered clothing stop inking across the body; silhouettes stay.'),
|
|
64
|
+
hair: numberField(0, 0, 0.2, 'Push hair hull lines back in depth (metres): edges of overlapping locks stop inking; the hair silhouette stays.'),
|
|
65
|
+
skin: numberField(0, 0, 0.2, 'Push body-skin hull lines back in depth (metres).'),
|
|
66
|
+
metal: numberField(0, 0, 0.2, 'Push metal hull lines back in depth (metres).'),
|
|
67
|
+
}),
|
|
59
68
|
honourSourceOff: booleanField(true, 'Respect a source material\'s "no outline" (MMD edge flag off).'),
|
|
60
69
|
widthMap: textureField('Per-texel width multiplier (also `userData.toonOutlineWidthMap`, MToon outline width texture).'),
|
|
61
70
|
widthMapChannel: selectField(0, [0, 1, 2, 3], 'Width map channel.', { numeric: true, optionLabels: { 0: 'Red', 1: 'Green', 2: 'Blue', 3: 'Alpha' } }),
|
|
@@ -1168,12 +1168,17 @@ function runAutoRoles(root, toonSettings, materialRoleOverrides, modelFacts, rep
|
|
|
1168
1168
|
report.add('warn', 'no-face-role', 'No material is recognised as a face and automatic roles are off; the face is shaded with the body terminator.');
|
|
1169
1169
|
return null;
|
|
1170
1170
|
}
|
|
1171
|
+
const declaredForward = Array.isArray(root.userData?.toonForward);
|
|
1171
1172
|
const result = inferAutoRoleWeights(root, {
|
|
1172
1173
|
headBone: findHeadBone(root),
|
|
1173
1174
|
isEligible: (mat) => !(mat?.isMToonMaterial && mat.isOutline) &&
|
|
1174
1175
|
AUTO_ROLE_ELIGIBLE.has(classifyMaterialRole(mat, materialRoleOverrides).role),
|
|
1175
1176
|
skinTolerance: toonSettings.autoRoles.skinTolerance,
|
|
1176
1177
|
});
|
|
1178
|
+
if (!declaredForward && Array.isArray(root.userData?.toonForward)) {
|
|
1179
|
+
const forward = root.userData.toonForward;
|
|
1180
|
+
report.add('warn', 'rest-forward-inferred', `The model declares no forward and its +Z analysis found no skin in the cheek band; its rest pose faces [${forward.join(', ')}] (inferred from the face) and that was written to root.userData.toonForward. Declare it on the model to make this explicit.`, { forward });
|
|
1181
|
+
}
|
|
1177
1182
|
if (result.applied) {
|
|
1178
1183
|
report.add('info', 'auto-roles-applied', `No material is named as a face; inferred face, skin and hair per vertex (${result.method}).`, result);
|
|
1179
1184
|
} else {
|
package/src/version.js
CHANGED
|
@@ -3,25 +3,21 @@
|
|
|
3
3
|
* `isEligible(material)` selects materials whose roles are unknown.
|
|
4
4
|
* Returns a report; `applied` is false (with a `reason`) when evidence is
|
|
5
5
|
* insufficient, in which case nothing is modified.
|
|
6
|
+
*
|
|
7
|
+
* The analysis runs in the declared character frame (`root.userData.toonForward`,
|
|
8
|
+
* +Z when none is declared). Only when a model declares nothing and its +Z
|
|
9
|
+
* analysis finds no skin in the cheek band is its rest-pose facing estimated
|
|
10
|
+
* (estimateRestForward) and the analysis retried from that side. The estimate
|
|
11
|
+
* is a fallback, never an override: light warm hair (ash, silver, blond) is
|
|
12
|
+
* skin-coloured by hue and can outweigh the face in it. The inferred axis is
|
|
13
|
+
* kept, and written to the root so later bakes and head tracking agree, only
|
|
14
|
+
* when the retry succeeds.
|
|
6
15
|
*/
|
|
7
16
|
export function inferAutoRoleWeights(root: any, { headBone, isEligible, skinTolerance, }?: {
|
|
8
17
|
headBone?: any;
|
|
9
18
|
isEligible?: () => boolean;
|
|
10
19
|
skinTolerance?: number;
|
|
11
20
|
}): {
|
|
12
|
-
applied: boolean;
|
|
13
|
-
reason: string;
|
|
14
|
-
medianRgb?: undefined;
|
|
15
|
-
referenceSkinFraction?: undefined;
|
|
16
|
-
counts?: undefined;
|
|
17
|
-
headVertexCount?: undefined;
|
|
18
|
-
meshes?: undefined;
|
|
19
|
-
faceFrame?: undefined;
|
|
20
|
-
maskedMeshes?: undefined;
|
|
21
|
-
method?: undefined;
|
|
22
|
-
skinReference?: undefined;
|
|
23
|
-
skinTolerance?: undefined;
|
|
24
|
-
} | {
|
|
25
21
|
applied: boolean;
|
|
26
22
|
medianRgb: any[];
|
|
27
23
|
reason: string;
|
|
@@ -30,9 +26,12 @@ export function inferAutoRoleWeights(root: any, { headBone, isEligible, skinTole
|
|
|
30
26
|
headVertexCount?: undefined;
|
|
31
27
|
meshes?: undefined;
|
|
32
28
|
faceFrame?: undefined;
|
|
29
|
+
forwardInferred?: undefined;
|
|
30
|
+
head?: undefined;
|
|
33
31
|
maskedMeshes?: undefined;
|
|
34
32
|
method?: undefined;
|
|
35
33
|
skinReference?: undefined;
|
|
34
|
+
skinSplit?: undefined;
|
|
36
35
|
skinTolerance?: undefined;
|
|
37
36
|
} | {
|
|
38
37
|
applied: boolean;
|
|
@@ -49,12 +48,31 @@ export function inferAutoRoleWeights(root: any, { headBone, isEligible, skinTole
|
|
|
49
48
|
x: number;
|
|
50
49
|
y: number;
|
|
51
50
|
};
|
|
51
|
+
forwardInferred: {
|
|
52
|
+
forward: number[];
|
|
53
|
+
samples: number;
|
|
54
|
+
strength: number;
|
|
55
|
+
};
|
|
56
|
+
head: {
|
|
57
|
+
center: THREE.Vector3Tuple;
|
|
58
|
+
radiusX: number;
|
|
59
|
+
radiusY: number;
|
|
60
|
+
};
|
|
52
61
|
maskedMeshes: number;
|
|
53
62
|
method: string;
|
|
63
|
+
referenceSkinFraction: number;
|
|
54
64
|
skinReference: any[];
|
|
65
|
+
skinSplit: {
|
|
66
|
+
boundary: number;
|
|
67
|
+
groups: any[];
|
|
68
|
+
hair: string;
|
|
69
|
+
};
|
|
55
70
|
skinTolerance: number;
|
|
56
71
|
reason?: undefined;
|
|
57
72
|
medianRgb?: undefined;
|
|
58
|
-
|
|
73
|
+
} | {
|
|
74
|
+
applied: boolean;
|
|
75
|
+
reason: string;
|
|
59
76
|
};
|
|
60
77
|
export const AUTO_ROLE_ATTRIBUTE: "toonRoleWeights";
|
|
78
|
+
import * as THREE from 'three';
|
|
@@ -62,6 +62,12 @@ export const OUTLINE_FIELDS: Readonly<{
|
|
|
62
62
|
}>;
|
|
63
63
|
}>;
|
|
64
64
|
faceDepthPush: import("./fieldSchema.js").SettingField;
|
|
65
|
+
depthPush: Readonly<{
|
|
66
|
+
cloth: import("./fieldSchema.js").SettingField;
|
|
67
|
+
hair: import("./fieldSchema.js").SettingField;
|
|
68
|
+
skin: import("./fieldSchema.js").SettingField;
|
|
69
|
+
metal: import("./fieldSchema.js").SettingField;
|
|
70
|
+
}>;
|
|
65
71
|
honourSourceOff: import("./fieldSchema.js").SettingField;
|
|
66
72
|
widthMap: import("./fieldSchema.js").SettingField;
|
|
67
73
|
widthMapChannel: import("./fieldSchema.js").SettingField;
|
|
@@ -18,32 +18,6 @@ export function findPrimarySkinnedMesh(root: any): any;
|
|
|
18
18
|
*/
|
|
19
19
|
export function applyToonShader(root: any, options?: {}): {
|
|
20
20
|
autoRoles: {
|
|
21
|
-
applied: boolean;
|
|
22
|
-
reason: string;
|
|
23
|
-
medianRgb?: undefined;
|
|
24
|
-
referenceSkinFraction?: undefined;
|
|
25
|
-
counts?: undefined;
|
|
26
|
-
headVertexCount?: undefined;
|
|
27
|
-
meshes?: undefined;
|
|
28
|
-
faceFrame?: undefined;
|
|
29
|
-
maskedMeshes?: undefined;
|
|
30
|
-
method?: undefined;
|
|
31
|
-
skinReference?: undefined;
|
|
32
|
-
skinTolerance?: undefined;
|
|
33
|
-
} | {
|
|
34
|
-
applied: boolean;
|
|
35
|
-
medianRgb: any[];
|
|
36
|
-
reason: string;
|
|
37
|
-
referenceSkinFraction: number;
|
|
38
|
-
counts?: undefined;
|
|
39
|
-
headVertexCount?: undefined;
|
|
40
|
-
meshes?: undefined;
|
|
41
|
-
faceFrame?: undefined;
|
|
42
|
-
maskedMeshes?: undefined;
|
|
43
|
-
method?: undefined;
|
|
44
|
-
skinReference?: undefined;
|
|
45
|
-
skinTolerance?: undefined;
|
|
46
|
-
} | {
|
|
47
21
|
applied: boolean;
|
|
48
22
|
counts: {
|
|
49
23
|
face: number;
|
|
@@ -58,13 +32,31 @@ export function applyToonShader(root: any, options?: {}): {
|
|
|
58
32
|
x: number;
|
|
59
33
|
y: number;
|
|
60
34
|
};
|
|
35
|
+
forwardInferred: {
|
|
36
|
+
forward: number[];
|
|
37
|
+
samples: number;
|
|
38
|
+
strength: number;
|
|
39
|
+
};
|
|
40
|
+
head: {
|
|
41
|
+
center: THREE.Vector3Tuple;
|
|
42
|
+
radiusX: number;
|
|
43
|
+
radiusY: number;
|
|
44
|
+
};
|
|
61
45
|
maskedMeshes: number;
|
|
62
46
|
method: string;
|
|
47
|
+
referenceSkinFraction: number;
|
|
63
48
|
skinReference: any[];
|
|
49
|
+
skinSplit: {
|
|
50
|
+
boundary: number;
|
|
51
|
+
groups: any[];
|
|
52
|
+
hair: string;
|
|
53
|
+
};
|
|
64
54
|
skinTolerance: number;
|
|
65
55
|
reason?: undefined;
|
|
66
56
|
medianRgb?: undefined;
|
|
67
|
-
|
|
57
|
+
} | {
|
|
58
|
+
applied: boolean;
|
|
59
|
+
reason: string;
|
|
68
60
|
};
|
|
69
61
|
convertedMeshCount: number;
|
|
70
62
|
debugOutputMode: {
|
|
@@ -114,6 +106,7 @@ export const TOON_DEBUG_OUTPUT_MODES: Readonly<{
|
|
|
114
106
|
}>;
|
|
115
107
|
/** Debug view name → label. */
|
|
116
108
|
export const TOON_DEBUG_OUTPUT_LABELS: any;
|
|
109
|
+
import * as THREE from 'three';
|
|
117
110
|
export { alphaTestForMaterial, createAlphaSettings, DEFAULT_ALPHA_SETTINGS, resolveAlphaForMaterial, sourceOpacity, usesAlphaBlend, usesAlphaCutout } from "./settings/alphaSettings.js";
|
|
118
111
|
export { BASE_TEXTURE_MATERIAL_COLOR_MODES, BASE_TEXTURE_SATURATION_MODES, createBaseTextureSettings, DEFAULT_BASE_TEXTURE_SETTINGS } from "./settings/baseTextureSettings.js";
|
|
119
112
|
export { createFurSettings, DEFAULT_FUR_SETTINGS, materialUsesFur } from "./settings/furSettings.js";
|
package/types/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TOONLAB_VERSION: "0.5.
|
|
1
|
+
export const TOONLAB_VERSION: "0.5.1";
|