@call-me-sensei/toonlab 0.4.24 → 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 +7 -6
- package/README.md +4 -4
- package/agents/PROMPTS.md +1 -1
- package/agents/skills/claude/toon-shading/SKILL.md +1 -1
- package/agents/skills/codex/toon-shading/SKILL.md +1 -1
- package/package.json +4 -2
- package/src/character/modelLoader.js +42 -1
- package/src/core/materialRoles.js +40 -5
- package/src/environment/environmentMaterialAdapter.js +3 -3
- package/src/post/postProcessing.js +12 -0
- package/src/shaders-tsl/character/castShadows.js +147 -0
- package/src/shaders-tsl/character/characterMaterial.js +766 -0
- package/src/shaders-tsl/character/characterState.js +90 -0
- package/src/shaders-tsl/character/debugViews.js +34 -0
- package/src/shaders-tsl/character/displayColor.js +71 -0
- package/src/shaders-tsl/character/highlightsRim.js +123 -0
- package/src/shaders-tsl/character/paintedDetails.js +86 -0
- package/src/shaders-tsl/character/sceneLights.js +229 -0
- package/src/shaders-tsl/character/surfaceLight.js +148 -0
- package/src/shaders-tsl/chunks/character-roles.js +10 -3
- package/src/shaders-tsl/chunks/environment-lighting.js +2 -2
- package/src/shaders-tsl/chunks/pass-depth-color.js +11 -2
- package/src/shaders-tsl/environment.js +2 -2
- package/src/shaders-tsl/flower.js +14 -2
- package/src/shaders-tsl/post-composite.js +14 -4
- package/src/styles/neutralStylePresets.js +12 -42
- package/src/toon/alphaCoverage.js +82 -0
- package/src/toon/autoRoles.js +776 -0
- package/src/toon/bakeAttribute.js +86 -0
- package/src/toon/characterParameters.js +272 -0
- package/src/toon/characterRenderPasses.js +430 -549
- package/src/toon/faceShadowBake.js +547 -0
- package/src/toon/featureBakes.js +353 -0
- package/src/toon/headBone.js +139 -0
- package/src/toon/occlusionBake.js +236 -0
- package/src/toon/settings/alphaSettings.js +68 -7
- package/src/toon/settings/autoRolesSettings.js +24 -0
- package/src/toon/settings/baseTextureSettings.js +21 -3
- package/src/toon/settings/faceSettings.js +56 -0
- package/src/toon/settings/fieldSchema.js +324 -0
- package/src/toon/settings/furSettings.js +1 -1
- package/src/toon/settings/highlightSettings.js +67 -0
- package/src/toon/settings/lightSettings.js +44 -0
- package/src/toon/settings/mapsSettings.js +132 -0
- package/src/toon/settings/outlineSettings.js +70 -254
- package/src/toon/settings/rampSettings.js +38 -0
- package/src/toon/settings/rimSettings.js +46 -0
- package/src/toon/settings/shadingSettings.js +42 -0
- package/src/toon/settings/shadowSettings.js +54 -0
- package/src/toon/skinEvidence.js +102 -0
- package/src/toon/sourceShading.js +176 -0
- package/src/toon/texturePixels.js +196 -0
- package/src/toon/toonMaterialAdapter.js +997 -1697
- package/src/toon/toonSettings.js +345 -923
- package/src/version.js +1 -1
- package/types/cloud/noise/weatherMap.d.ts +1 -1
- package/types/index.d.ts +18 -61
- package/types/post/postGenerator.d.ts +1 -0
- package/types/shaders-tsl/character/castShadows.d.ts +29 -0
- package/types/shaders-tsl/character/characterMaterial.d.ts +61 -0
- package/types/shaders-tsl/character/characterState.d.ts +27 -0
- package/types/shaders-tsl/character/debugViews.d.ts +6 -0
- package/types/shaders-tsl/character/displayColor.d.ts +13 -0
- package/types/shaders-tsl/character/highlightsRim.d.ts +43 -0
- package/types/shaders-tsl/character/paintedDetails.d.ts +20 -0
- package/types/shaders-tsl/character/sceneLights.d.ts +67 -0
- package/types/shaders-tsl/character/surfaceLight.d.ts +28 -0
- package/types/shaders-tsl/chunks/character-roles.d.ts +3 -2
- package/types/shaders-tsl/chunks/pass-depth-color.d.ts +3 -1
- package/types/shaders-tsl/post-composite.d.ts +0 -4
- package/types/styles/neutralStylePresets.d.ts +22 -415
- package/types/toon/alphaCoverage.d.ts +28 -0
- package/types/toon/autoRoles.d.ts +78 -0
- package/types/toon/bakeAttribute.d.ts +18 -0
- package/types/toon/characterParameters.d.ts +103 -0
- package/types/toon/characterRenderPasses.d.ts +45 -3
- package/types/toon/faceShadowBake.d.ts +31 -0
- package/types/toon/featureBakes.d.ts +20 -0
- package/types/toon/headBone.d.ts +34 -0
- package/types/toon/occlusionBake.d.ts +18 -0
- package/types/toon/settings/alphaSettings.d.ts +29 -3
- package/types/toon/settings/autoRolesSettings.d.ts +12 -0
- package/types/toon/settings/faceSettings.d.ts +43 -0
- package/types/toon/settings/fieldSchema.d.ts +101 -0
- package/types/toon/settings/highlightSettings.d.ts +77 -0
- package/types/toon/settings/lightSettings.d.ts +30 -0
- package/types/toon/settings/mapsSettings.d.ts +62 -0
- package/types/toon/settings/outlineSettings.d.ts +76 -124
- package/types/toon/settings/rampSettings.d.ts +26 -0
- package/types/toon/settings/rimSettings.d.ts +34 -0
- package/types/toon/settings/shadingSettings.d.ts +30 -0
- package/types/toon/settings/shadowSettings.d.ts +37 -0
- package/types/toon/skinEvidence.d.ts +14 -0
- package/types/toon/sourceShading.d.ts +36 -0
- package/types/toon/texturePixels.d.ts +25 -0
- package/types/toon/toonMaterialAdapter.d.ts +96 -855
- package/types/toon/toonSettings.d.ts +193 -1101
- package/types/version.d.ts +1 -1
- package/src/shaders-tsl/anime.js +0 -970
- package/src/shaders-tsl/chunks/character-color.js +0 -64
- package/src/shaders-tsl/chunks/character-highlights.js +0 -174
- package/src/shaders-tsl/chunks/character-lighting.js +0 -309
- package/src/shaders-tsl/chunks/character-material-maps.js +0 -146
- package/src/shaders-tsl/chunks/character-scene-lights.js +0 -270
- package/src/shaders-tsl/chunks/character-shadow-color.js +0 -61
- package/src/toon/settings/averageShadowSettings.js +0 -181
- package/src/toon/settings/celShadeSettings.js +0 -96
- package/src/toon/settings/contactShadowSettings.js +0 -114
- package/src/toon/settings/eyeHighlightSettings.js +0 -187
- package/src/toon/settings/faceLightingSettings.js +0 -135
- package/src/toon/settings/glitterSettings.js +0 -102
- package/src/toon/settings/hairHighlightSettings.js +0 -325
- package/src/toon/settings/indirectLightSettings.js +0 -244
- package/src/toon/settings/localLightSettings.js +0 -176
- package/src/toon/settings/materialMapSettings.js +0 -299
- package/src/toon/settings/perspectiveRemovalSettings.js +0 -60
- package/src/toon/settings/rimLightSettings.js +0 -268
- package/src/toon/settings/sceneShadowSettings.js +0 -147
- package/src/toon/settings/selfShadowSettings.js +0 -221
- package/src/toon/settings/shadowColorSettings.js +0 -238
- package/src/toon/settings/skinToneSettings.js +0 -150
- package/src/toon/settings/specularSettings.js +0 -314
- package/types/shaders-tsl/anime.d.ts +0 -9
- package/types/shaders-tsl/chunks/character-color.d.ts +0 -6
- package/types/shaders-tsl/chunks/character-highlights.d.ts +0 -12
- package/types/shaders-tsl/chunks/character-lighting.d.ts +0 -20
- package/types/shaders-tsl/chunks/character-material-maps.d.ts +0 -18
- package/types/shaders-tsl/chunks/character-scene-lights.d.ts +0 -48
- package/types/shaders-tsl/chunks/character-shadow-color.d.ts +0 -5
- package/types/toon/settings/averageShadowSettings.d.ts +0 -39
- package/types/toon/settings/celShadeSettings.d.ts +0 -37
- package/types/toon/settings/contactShadowSettings.d.ts +0 -30
- package/types/toon/settings/eyeHighlightSettings.d.ts +0 -55
- package/types/toon/settings/faceLightingSettings.d.ts +0 -32
- package/types/toon/settings/glitterSettings.d.ts +0 -43
- package/types/toon/settings/hairHighlightSettings.d.ts +0 -89
- package/types/toon/settings/indirectLightSettings.d.ts +0 -43
- package/types/toon/settings/localLightSettings.d.ts +0 -46
- package/types/toon/settings/materialMapSettings.d.ts +0 -86
- package/types/toon/settings/perspectiveRemovalSettings.d.ts +0 -14
- package/types/toon/settings/rimLightSettings.d.ts +0 -79
- package/types/toon/settings/sceneShadowSettings.d.ts +0 -32
- package/types/toon/settings/selfShadowSettings.d.ts +0 -44
- package/types/toon/settings/shadowColorSettings.d.ts +0 -77
- package/types/toon/settings/skinToneSettings.d.ts +0 -30
- package/types/toon/settings/specularSettings.d.ts +0 -96
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
// The toon character node material (spec §5).
|
|
2
|
+
//
|
|
3
|
+
// One NodeMaterial per converted source material. Lighting is computed here
|
|
4
|
+
// from the shared scene-light uniforms (sceneLights.js) rather than three's
|
|
5
|
+
// light nodes: the look is a painted one — the lit side is the albedo under
|
|
6
|
+
// the light's colour, the shadow side the albedo times a display-space tone —
|
|
7
|
+
// so the material is unlit as far as three is concerned.
|
|
8
|
+
//
|
|
9
|
+
// Per-material inputs are uniforms named after the parameters in
|
|
10
|
+
// src/toon/characterParameters.js (`material.uniforms[name].value`), so a live
|
|
11
|
+
// retune is a uniform write. Maps and optional features are graph-build
|
|
12
|
+
// gates (`features`): an absent map never enters the graph.
|
|
13
|
+
|
|
14
|
+
import * as THREE from 'three';
|
|
15
|
+
import {
|
|
16
|
+
abs,
|
|
17
|
+
attribute,
|
|
18
|
+
cameraViewMatrix,
|
|
19
|
+
cameraPosition,
|
|
20
|
+
cameraProjectionMatrix,
|
|
21
|
+
cameraFar,
|
|
22
|
+
cameraNear,
|
|
23
|
+
clamp,
|
|
24
|
+
cross,
|
|
25
|
+
Discard,
|
|
26
|
+
dot,
|
|
27
|
+
float,
|
|
28
|
+
Fn,
|
|
29
|
+
fract,
|
|
30
|
+
frontFacing,
|
|
31
|
+
fwidth,
|
|
32
|
+
If,
|
|
33
|
+
inverseSqrt,
|
|
34
|
+
max,
|
|
35
|
+
min,
|
|
36
|
+
mix,
|
|
37
|
+
normalize,
|
|
38
|
+
normalViewGeometry,
|
|
39
|
+
normalWorldGeometry,
|
|
40
|
+
positionView,
|
|
41
|
+
positionWorld,
|
|
42
|
+
screenCoordinate,
|
|
43
|
+
screenUV,
|
|
44
|
+
select,
|
|
45
|
+
smoothstep,
|
|
46
|
+
texture,
|
|
47
|
+
transformNormalToView,
|
|
48
|
+
normalLocal,
|
|
49
|
+
uniform,
|
|
50
|
+
uv,
|
|
51
|
+
vec2,
|
|
52
|
+
vec3,
|
|
53
|
+
vec4,
|
|
54
|
+
vertexColor,
|
|
55
|
+
viewZToOrthographicDepth,
|
|
56
|
+
viewZToPerspectiveDepth,
|
|
57
|
+
dFdx,
|
|
58
|
+
dFdy,
|
|
59
|
+
hash,
|
|
60
|
+
} from 'three/tsl';
|
|
61
|
+
import { NodeMaterial } from 'three/webgpu';
|
|
62
|
+
|
|
63
|
+
import { createRolesChunk } from '../chunks/character-roles.js';
|
|
64
|
+
import { withToonStorageSkinning } from '../chunks/character-skinning.js';
|
|
65
|
+
import { sampleEnvironmentSunShadowWithNormal } from '../chunks/environment-sun-shadow.js';
|
|
66
|
+
import { PassBasicNodeMaterial } from '../chunks/pass-depth-color.js';
|
|
67
|
+
import { sampleEnvironmentCloudShadow } from '../../sky/cloudShadow.js';
|
|
68
|
+
import { characterShadowVisibility, decodeStoredDepth, FACE_DEPTH_OFFSET, prepassDistance, screenSpaceShadowVisibility } from './castShadows.js';
|
|
69
|
+
import { bindMaterialToCharacterState } from './characterState.js';
|
|
70
|
+
import { CHARACTER_DEBUG_VIEW_IDS } from './debugViews.js';
|
|
71
|
+
import { displayChroma, displayMultiply, hsvToRgb, luma, maxComponent, minComponent, rgbToHsv, toDisplay, toLinear } from './displayColor.js';
|
|
72
|
+
import { hairRing, matcapUV, rimAmount, stylizedSpecular } from './highlightsRim.js';
|
|
73
|
+
import { eyeWhiteShade, noseShadow, sheerStreak } from './paintedDetails.js';
|
|
74
|
+
import { characterLight, limitSaturation, localLightBands, shadingDirection } from './surfaceLight.js';
|
|
75
|
+
|
|
76
|
+
const BAKE_ATTRIBUTE = 'toonBake';
|
|
77
|
+
const ROLE_WEIGHT_ATTRIBUTE = 'toonRoleWeights';
|
|
78
|
+
const WEIGHTED_SUFFIXES = [['Skin', 'skin'], ['Face', 'face'], ['Hair', 'hair']];
|
|
79
|
+
|
|
80
|
+
const SkinnedNodeMaterial = withToonStorageSkinning(NodeMaterial);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Node material class of converted characters. `viewOffset(positionView)`,
|
|
84
|
+
* when set, displaces the view-space position after skinning (outline hulls,
|
|
85
|
+
* features drawn over the bangs) on every backend.
|
|
86
|
+
*/
|
|
87
|
+
export class ToonCharacterNodeMaterial extends SkinnedNodeMaterial {
|
|
88
|
+
static get type() {
|
|
89
|
+
return 'ToonCharacterNodeMaterial';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
constructor(parameters) {
|
|
93
|
+
super();
|
|
94
|
+
this.isToonCharacterMaterial = true;
|
|
95
|
+
this.lights = false;
|
|
96
|
+
this.viewOffset = null;
|
|
97
|
+
this.setValues(parameters);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setupPositionView(builder) {
|
|
101
|
+
const base = super.setupPositionView(builder);
|
|
102
|
+
return this.viewOffset ? base.add(this.viewOffset(base)) : base;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Uniforms.
|
|
108
|
+
|
|
109
|
+
function uniformFor(value) {
|
|
110
|
+
if (value?.isColor) return uniform(new THREE.Vector3(value.r, value.g, value.b));
|
|
111
|
+
if (value?.isVector2 || value?.isVector3 || value?.isVector4) return uniform(value.clone());
|
|
112
|
+
if (Array.isArray(value)) {
|
|
113
|
+
if (value.length === 2) return uniform(new THREE.Vector2(...value));
|
|
114
|
+
if (value.length === 3) return uniform(new THREE.Vector3(...value));
|
|
115
|
+
if (value.length === 4) return uniform(new THREE.Vector4(...value));
|
|
116
|
+
}
|
|
117
|
+
if (typeof value === 'boolean') return uniform(value ? 1 : 0);
|
|
118
|
+
return uniform(Number.isFinite(value) ? value : 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeUniform(node, value) {
|
|
122
|
+
if (!node || value === undefined || value === null) return;
|
|
123
|
+
const current = node.value;
|
|
124
|
+
if (current?.isVector2 || current?.isVector3 || current?.isVector4) {
|
|
125
|
+
if (value?.isColor) current.set(value.r, value.g, value.b);
|
|
126
|
+
else if (Array.isArray(value)) current.fromArray(value);
|
|
127
|
+
else if (value?.isVector2 || value?.isVector3 || value?.isVector4) current.copy(value);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === 'boolean') node.value = value ? 1 : 0;
|
|
131
|
+
else if (Number.isFinite(value)) node.value = value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Writes parameter values into a character material's uniforms. */
|
|
135
|
+
export function applyCharacterParameters(material, params) {
|
|
136
|
+
const uniforms = material?.uniforms;
|
|
137
|
+
if (!uniforms) return 0;
|
|
138
|
+
let written = 0;
|
|
139
|
+
for (const [name, value] of Object.entries(params ?? {})) {
|
|
140
|
+
if (!uniforms[name]) continue;
|
|
141
|
+
writeUniform(uniforms[name], value);
|
|
142
|
+
written += 1;
|
|
143
|
+
}
|
|
144
|
+
return written;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Inputs every graph may read; conversion supplies real values.
|
|
148
|
+
const MATERIAL_INPUT_DEFAULTS = Object.freeze({
|
|
149
|
+
baseAlphaStrength: 1,
|
|
150
|
+
baseColor: [1, 1, 1],
|
|
151
|
+
baseSaturation: 1,
|
|
152
|
+
emissiveColor: [0, 0, 0],
|
|
153
|
+
shellDensity: 3,
|
|
154
|
+
shellSag: 0.35,
|
|
155
|
+
shellLength: 0.02,
|
|
156
|
+
shellRootOffset: -0.2,
|
|
157
|
+
shellRootShade: 0.55,
|
|
158
|
+
noseCenter: [0.5, 0.5],
|
|
159
|
+
noseLeaf: [0.01, 0.01],
|
|
160
|
+
outlineColorOverride: [0, 0, 0, 0],
|
|
161
|
+
outlineOverrideLightingMix: 1,
|
|
162
|
+
shadeColor: [1, 1, 1],
|
|
163
|
+
shadeStrength: 0,
|
|
164
|
+
sourceAoIntensity: 1,
|
|
165
|
+
sourceMetalness: 0,
|
|
166
|
+
sourceNormalScale: 1,
|
|
167
|
+
stickerBlend: 0,
|
|
168
|
+
stickerOffset: [0, 0],
|
|
169
|
+
stickerRepeat: [1, 1],
|
|
170
|
+
stickerStrength: 0,
|
|
171
|
+
toneOverride: [1, 1, 1, 0],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Shared helpers.
|
|
176
|
+
|
|
177
|
+
function uvNode(channel) {
|
|
178
|
+
return channel === 'bake' ? attribute(BAKE_ATTRIBUTE, 'vec4').xy : uv(channel ?? 0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Role weights at this pixel (or vertex): skin, face, hair and the
|
|
182
|
+
// material's own role. Parameters blend by these weights.
|
|
183
|
+
function roleWeights(features, { vertex = false } = {}) {
|
|
184
|
+
if (features.roleWeights === 'attribute') {
|
|
185
|
+
const weights = attribute(ROLE_WEIGHT_ATTRIBUTE, 'vec4');
|
|
186
|
+
return { face: weights.y, hair: weights.z, skin: weights.x };
|
|
187
|
+
}
|
|
188
|
+
if (features.roleWeights === 'mask' && features.roleMaskMap) {
|
|
189
|
+
const node = texture(features.roleMaskMap, uv(0));
|
|
190
|
+
const weights = vertex ? node.level(0) : node;
|
|
191
|
+
return { face: weights.y, hair: weights.z, skin: weights.x };
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function createBlend(u, weights) {
|
|
197
|
+
if (!weights) return (name) => u[name];
|
|
198
|
+
const own = clamp(float(1).sub(weights.skin).sub(weights.face).sub(weights.hair), 0, 1);
|
|
199
|
+
return (name) => {
|
|
200
|
+
let value = u[name].mul(own);
|
|
201
|
+
for (const [suffix, role] of WEIGHTED_SUFFIXES) value = value.add(u[`${name}${suffix}`].mul(weights[role]));
|
|
202
|
+
return value;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function roleWeightOf(role, features, weights, name) {
|
|
207
|
+
if (role === name) return float(1);
|
|
208
|
+
return weights ? weights[name] : float(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function headFrame(u, state) {
|
|
212
|
+
const forward = normalize(mix(state.headRestForward, state.headForward, u.faceHeadTracked));
|
|
213
|
+
const upRaw = normalize(mix(state.headRestUp, state.headUp, u.faceHeadTracked));
|
|
214
|
+
const right = normalize(cross(forward, upRaw));
|
|
215
|
+
const up = normalize(cross(right, forward));
|
|
216
|
+
return { center: state.headSphereCenter, forward, right, up };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Screen-door fade (interleaved gradient noise), no sorting needed.
|
|
220
|
+
function ditherDiscard(u) {
|
|
221
|
+
If(u.ditherOpacity.lessThan(0.999), () => {
|
|
222
|
+
const pixel = screenCoordinate.xy.floor();
|
|
223
|
+
const threshold = fract(fract(pixel.x.mul(0.06711056).add(pixel.y.mul(0.00583715))).mul(52.9829189));
|
|
224
|
+
If(u.ditherOpacity.lessThanEqual(threshold), () => {
|
|
225
|
+
Discard();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// With the depth prepass available, fragments well behind the nearest
|
|
231
|
+
// surface (the hair layers under the outer hair, the body under the cloth)
|
|
232
|
+
// are discarded before any shading: cutout materials cannot use early depth
|
|
233
|
+
// rejection, so this is where overdraw is saved.
|
|
234
|
+
function hiddenSurfaceDiscard(state, viewPosition) {
|
|
235
|
+
If(state.depthReady.greaterThan(0.5), () => {
|
|
236
|
+
const nearest = prepassDistance(decodeStoredDepth(state.depthMap.sample(screenUV).level(0).x).depth);
|
|
237
|
+
const own = viewPosition.z.negate();
|
|
238
|
+
If(own.greaterThan(nearest.add(max(own.mul(0.002), 0.004))), () => {
|
|
239
|
+
Discard();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Base texture × material colour, with the base-colour edits, decal and
|
|
245
|
+
// detail layers.
|
|
246
|
+
function albedoNodes(u, features) {
|
|
247
|
+
const base = features.baseMap ? texture(features.baseMap) : vec4(1, 1, 1, 1);
|
|
248
|
+
const source = base.rgb.mul(u.baseColor).toVar('toonSourceAlbedo');
|
|
249
|
+
let albedo = mix(vec3(luma(source)), source, u.baseSaturation);
|
|
250
|
+
if (features.vertexColors) albedo = albedo.mul(vertexColor().rgb);
|
|
251
|
+
if (features.sticker?.map) {
|
|
252
|
+
const decalUv = uv(features.sticker.uvChannel ?? 0).mul(u.stickerRepeat).add(u.stickerOffset);
|
|
253
|
+
const decal = texture(features.sticker.map, decalUv);
|
|
254
|
+
const amount = decal.a.mul(u.stickerStrength);
|
|
255
|
+
const normal = mix(albedo, decal.rgb, amount);
|
|
256
|
+
const add = albedo.add(decal.rgb.mul(amount));
|
|
257
|
+
const multiply = mix(albedo, albedo.mul(decal.rgb), amount);
|
|
258
|
+
albedo = normal.mul(select(u.stickerBlend.lessThan(0.5), float(1), float(0)))
|
|
259
|
+
.add(add.mul(select(u.stickerBlend.greaterThanEqual(0.5).and(u.stickerBlend.lessThan(1.5)), float(1), float(0))))
|
|
260
|
+
.add(multiply.mul(select(u.stickerBlend.greaterThanEqual(1.5), float(1), float(0))));
|
|
261
|
+
}
|
|
262
|
+
if (features.maps?.detailMap) {
|
|
263
|
+
const detail = texture(features.maps.detailMap, uv(0).mul(u.detailRepeat)).rgb;
|
|
264
|
+
albedo = albedo.mul(mix(vec3(1), detail.mul(2), u.detailStrength));
|
|
265
|
+
}
|
|
266
|
+
const alpha = mix(float(1), base.a, u.baseAlphaStrength);
|
|
267
|
+
return { alpha, albedo: albedo.toVar('toonAlbedo'), base, source };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Derivative-based tangent frame for a tangent-space normal map (world space).
|
|
271
|
+
function perturbNormal(u, features, normal, worldPosition) {
|
|
272
|
+
const sample = texture(features.maps.normalMap).xyz.mul(2).sub(1).toVar();
|
|
273
|
+
sample.xy.mulAssign(u.sourceNormalScale.mul(u.normalScale));
|
|
274
|
+
const uvCoord = uv(features.maps.normalMap.channel ?? 0);
|
|
275
|
+
const q0 = dFdx(worldPosition);
|
|
276
|
+
const q1 = dFdy(worldPosition);
|
|
277
|
+
const st0 = dFdx(uvCoord);
|
|
278
|
+
const st1 = dFdy(uvCoord);
|
|
279
|
+
const q1perp = cross(q1, normal);
|
|
280
|
+
const q0perp = cross(normal, q0);
|
|
281
|
+
const tangent = q1perp.mul(st0.x).add(q0perp.mul(st1.x));
|
|
282
|
+
const bitangent = q1perp.mul(st0.y).add(q0perp.mul(st1.y));
|
|
283
|
+
const det = max(dot(tangent, tangent), dot(bitangent, bitangent));
|
|
284
|
+
const scale = select(det.equal(0), float(0), inverseSqrt(max(det, 1e-20)));
|
|
285
|
+
const mapped = normalize(tangent.mul(sample.x.mul(scale)).add(bitangent.mul(sample.y.mul(scale))).add(normal.mul(sample.z)));
|
|
286
|
+
return normalize(mix(normal, mapped, u.normalStrength));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// The lit surface.
|
|
291
|
+
|
|
292
|
+
function surfaceColorNode(u, features, state) {
|
|
293
|
+
const role = features.role;
|
|
294
|
+
return Fn(() => {
|
|
295
|
+
// Shared once-nodes first (TSL: evaluate before nested helpers use them).
|
|
296
|
+
const geometryNormal = normalWorldGeometry.toVar('toonGeometryNormal');
|
|
297
|
+
const geometryNormalView = normalViewGeometry.toVar('toonGeometryNormalView');
|
|
298
|
+
const worldPosition = positionWorld.toVar('toonWorldPosition');
|
|
299
|
+
const viewPosition = positionView.toVar('toonViewPosition');
|
|
300
|
+
const normal = select(frontFacing, geometryNormal, geometryNormal.negate()).toVar('toonNormal');
|
|
301
|
+
const normalView = select(frontFacing, geometryNormalView, geometryNormalView.negate()).toVar('toonNormalView');
|
|
302
|
+
const viewDirection = normalize(cameraPosition.sub(worldPosition)).toVar('toonViewDirection');
|
|
303
|
+
|
|
304
|
+
// Discards only where the material needs them: a shader that may
|
|
305
|
+
// discard loses early depth rejection for every fragment.
|
|
306
|
+
if (features.dither) ditherDiscard(u);
|
|
307
|
+
if (features.furLayer) furDiscard(u, features.furLayer);
|
|
308
|
+
else if (features.cutout) hiddenSurfaceDiscard(state, viewPosition);
|
|
309
|
+
|
|
310
|
+
const weights = roleWeights(features);
|
|
311
|
+
const blend = createBlend(u, weights);
|
|
312
|
+
const faceWeight = roleWeightOf(role, features, weights, 'face');
|
|
313
|
+
const hairWeight = roleWeightOf(role, features, weights, 'hair');
|
|
314
|
+
const ownWeight = weights
|
|
315
|
+
? clamp(float(1).sub(weights.skin).sub(weights.face).sub(weights.hair), 0, 1)
|
|
316
|
+
: float(1);
|
|
317
|
+
const mayBeFace = role === 'face' || Boolean(weights);
|
|
318
|
+
const mayBeHair = role === 'hair' || Boolean(weights);
|
|
319
|
+
const bake = features.bake?.any ? attribute(BAKE_ATTRIBUTE, 'vec4') : null;
|
|
320
|
+
const head = headFrame(u, state);
|
|
321
|
+
|
|
322
|
+
const { alpha, albedo, source } = albedoNodes(u, features);
|
|
323
|
+
const { castDirection, direction: lightDirection } = shadingDirection(u);
|
|
324
|
+
|
|
325
|
+
// Painted details modify the albedo; shading applies on top.
|
|
326
|
+
const lateral = dot(lightDirection, head.right);
|
|
327
|
+
if (features.noseMark && bake) {
|
|
328
|
+
const nose = noseShadow(u, bake.xy, lateral);
|
|
329
|
+
albedo.assign(displayMultiply(albedo, mix(vec3(1), u.noseTint, nose.shadow).mul(float(1).add(nose.highlight.mul(0.035)))));
|
|
330
|
+
}
|
|
331
|
+
if (features.bake?.detail === 'lid' && bake) {
|
|
332
|
+
albedo.assign(displayMultiply(albedo, mix(vec3(1), u.eyeWhiteTint, eyeWhiteShade(u, bake.w))));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Lighting normal: normal map, then the smooth head-shaped field on faces.
|
|
336
|
+
let lightingNormal = normal;
|
|
337
|
+
if (features.maps?.normalMap) lightingNormal = perturbNormal(u, features, normal, worldPosition);
|
|
338
|
+
lightingNormal = lightingNormal.toVar('toonLightingNormal');
|
|
339
|
+
if (mayBeFace) {
|
|
340
|
+
const sphere = normalize(worldPosition.sub(head.center));
|
|
341
|
+
const faceNormal = normalize(mix(head.forward, sphere, u.faceNormalRoundness));
|
|
342
|
+
lightingNormal.assign(normalize(mix(lightingNormal, faceNormal, u.faceNormalAmount.mul(faceWeight).mul(state.headReady))));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const { light: baseLight, sky } = characterLight(u, normal);
|
|
346
|
+
|
|
347
|
+
// Terminator: one soft step at the blended terminator.
|
|
348
|
+
const bias = float(0).toVar('toonLightingBias');
|
|
349
|
+
if (features.bake?.occlusion && bake) {
|
|
350
|
+
bias.subAssign(blend('autoBiasStrength').mul(smoothstep(u.autoBiasStart, u.autoBiasEnd, bake.z)));
|
|
351
|
+
}
|
|
352
|
+
if (features.lightingMap) {
|
|
353
|
+
const mapValue = dot(texture(features.lightingMap, uvNode(features.lightingMapUv)), u.lightingMapChannel);
|
|
354
|
+
bias.addAssign(mapValue.sub(u.lightingMapPivot).mul(u.lightingMapScale));
|
|
355
|
+
}
|
|
356
|
+
const shade = dot(lightingNormal, lightDirection).add(bias).toVar('toonShade');
|
|
357
|
+
const terminator = blend('terminator');
|
|
358
|
+
const edge = max(blend('softness'), fwidth(shade).mul(u.antiAlias)).max(1e-4);
|
|
359
|
+
const terminatorLit = smoothstep(terminator.sub(edge), terminator.add(edge), shade).toVar('toonTerminatorLit');
|
|
360
|
+
const depthIntoShadow = max(terminator.sub(shade), 0).toVar('toonShadowDepth');
|
|
361
|
+
|
|
362
|
+
// Face map: a threshold compare of the light's azimuth around the head.
|
|
363
|
+
const faceMapLit = float(1).toVar('toonFaceMapLit');
|
|
364
|
+
const faceMapAmount = float(0).toVar('toonFaceMapAmount');
|
|
365
|
+
if (features.faceMap && mayBeFace) {
|
|
366
|
+
const horizontal = lightDirection.sub(head.up.mul(dot(lightDirection, head.up)));
|
|
367
|
+
const horizontalLength = horizontal.length();
|
|
368
|
+
const along = horizontal.div(max(horizontalLength, 1e-4));
|
|
369
|
+
const front = mix(float(1), dot(along, head.forward), smoothstep(0.02, 0.12, horizontalLength));
|
|
370
|
+
const side = dot(along, head.right);
|
|
371
|
+
const value = float(1).sub(front).mul(0.5);
|
|
372
|
+
const coords = uvNode(features.faceMapCoords);
|
|
373
|
+
const mirrored = vec2(u.faceMapMidU.mul(2).sub(coords.x), coords.y);
|
|
374
|
+
const mapUv = select(side.lessThan(0).and(u.faceMapMirror.greaterThan(0.5)), mirrored, coords);
|
|
375
|
+
const threshold = texture(features.faceMap, mapUv).x;
|
|
376
|
+
const softness = max(u.faceMapSoftness, fwidth(threshold).mul(0.75)).max(1e-4);
|
|
377
|
+
faceMapLit.assign(smoothstep(softness.negate(), softness, threshold.add(u.faceMapOffset).sub(value)));
|
|
378
|
+
faceMapAmount.assign(u.faceMapStrength.mul(faceWeight).mul(state.headReady));
|
|
379
|
+
}
|
|
380
|
+
const lit = mix(terminatorLit, faceMapLit, faceMapAmount).toVar('toonLit');
|
|
381
|
+
|
|
382
|
+
// Cast shadows move pixels into shadow.
|
|
383
|
+
const sunShadow = sampleEnvironmentSunShadowWithNormal(worldPosition, geometryNormal).toVar('toonSunShadow');
|
|
384
|
+
const cloudShadow = sampleEnvironmentCloudShadow(worldPosition, float(1)).toVar('toonCloudShadow');
|
|
385
|
+
const sceneRaw = sunShadow.mul(cloudShadow).toVar('toonSceneShadowRaw');
|
|
386
|
+
const sceneVisibility = mix(float(1), sceneRaw, blend('sceneShadowStrength'));
|
|
387
|
+
const faceLike = role === 'eye' ? float(1) : faceWeight;
|
|
388
|
+
// Cast shadows only matter where the surface is lit.
|
|
389
|
+
const castGate = lit.greaterThan(0.002);
|
|
390
|
+
const characterRaw = characterShadowVisibility(u, state, worldPosition, geometryNormal, faceLike, castGate);
|
|
391
|
+
const characterVisibility = mix(float(1), characterRaw, blend('characterShadowStrength'));
|
|
392
|
+
const screenRaw = screenSpaceShadowVisibility(u, state, viewPosition, castDirection, faceLike, castGate);
|
|
393
|
+
const screenVisibility = mix(float(1), screenRaw, blend('screenShadowStrength'));
|
|
394
|
+
let occlusion = float(1);
|
|
395
|
+
if (features.maps?.aoMap) {
|
|
396
|
+
const ao = texture(features.maps.aoMap).x;
|
|
397
|
+
occlusion = mix(float(1), ao, u.aoStrength.mul(u.sourceAoIntensity).clamp(0, 1));
|
|
398
|
+
}
|
|
399
|
+
const occlusionVar = occlusion.toVar('toonAo');
|
|
400
|
+
const castVisibility = sceneVisibility.mul(characterVisibility).mul(screenVisibility).mul(occlusionVar).toVar('toonCastVisibility');
|
|
401
|
+
const litAmount = lit.mul(castVisibility).toVar('toonLitAmount');
|
|
402
|
+
|
|
403
|
+
// Shadow colour: albedo × a display-space tone, the warmer band just
|
|
404
|
+
// inside the terminator (cast-shadowed areas take the plain tone).
|
|
405
|
+
const tone = blend('tone').toVar('toonTone');
|
|
406
|
+
const band = blend('bandColor');
|
|
407
|
+
const bandAmount = smoothstep(0.05, 0.11, displayChroma(albedo));
|
|
408
|
+
const castDepth = float(1).sub(castVisibility).mul(u.bandWidth).mul(1.5);
|
|
409
|
+
const bandDepth = max(depthIntoShadow.mul(float(1).sub(faceMapAmount)), castDepth);
|
|
410
|
+
const plain = select(u.bandWidth.greaterThan(1e-4), smoothstep(0, max(u.bandWidth, 1e-4), bandDepth), float(1));
|
|
411
|
+
const toneMultiplier = mix(mix(tone, band, bandAmount), tone, plain).toVar('toonToneMultiplier');
|
|
412
|
+
if (features.rampMap) {
|
|
413
|
+
const rampU = clamp(bandDepth.div(max(terminator.add(1), 0.05)), 0.005, 0.995);
|
|
414
|
+
const ramp = texture(features.rampMap, vec2(rampU, 0.5)).rgb;
|
|
415
|
+
toneMultiplier.assign(features.rampMapDecoded ? toDisplay(ramp) : ramp);
|
|
416
|
+
}
|
|
417
|
+
if (features.toneOverride) {
|
|
418
|
+
toneMultiplier.assign(mix(toneMultiplier, u.toneOverride.xyz, u.toneOverride.w.mul(ownWeight)));
|
|
419
|
+
}
|
|
420
|
+
const shadowColor = displayMultiply(albedo, toneMultiplier).toVar('toonShadowColor');
|
|
421
|
+
if (features.shadeOverride) {
|
|
422
|
+
let authored = u.shadeColor;
|
|
423
|
+
if (features.shadeMap) authored = authored.mul(texture(features.shadeMap).rgb);
|
|
424
|
+
const capped = authored.mul(min(float(1), luma(albedo).div(max(luma(authored), 1e-4))));
|
|
425
|
+
shadowColor.assign(mix(shadowColor, capped, u.shadeStrength.mul(ownWeight)));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// In the scenery's shade the light leans toward the sky's hue.
|
|
429
|
+
const skyHue = sky.div(max(maxComponent(sky), 1e-4));
|
|
430
|
+
const shadeLight = skyHue.mul(maxComponent(baseLight));
|
|
431
|
+
const light = mix(baseLight, shadeLight, u.shadeSkyTint.mul(float(1).sub(sceneRaw)).mul(u.lightEnabled)).toVar('toonShadedLight');
|
|
432
|
+
|
|
433
|
+
// The shadow side is lit by the light at its level but leans toward the
|
|
434
|
+
// sky's hue: under a warm sun, shadows stay cool and colourful rather
|
|
435
|
+
// than greyed out by the sun's colour.
|
|
436
|
+
const shadowSideRaw = mix(light, skyHue.mul(maxComponent(light)), u.shadowSkyTint.mul(u.lightEnabled));
|
|
437
|
+
// A saturated sky may cool the shadow side, but never turn it blue.
|
|
438
|
+
const shadowSideLight = limitSaturation(shadowSideRaw, u.lightMaxTint.mul(2))
|
|
439
|
+
.div(max(maxComponent(shadowSideRaw), 1e-5)).mul(maxComponent(light));
|
|
440
|
+
const color = mix(shadowColor.mul(shadowSideLight), albedo.mul(light), litAmount).toVar('toonColor');
|
|
441
|
+
|
|
442
|
+
// Local lights: their own cel bands, additive.
|
|
443
|
+
const local = localLightBands(u, worldPosition, lightingNormal);
|
|
444
|
+
color.addAssign(albedo.mul(local));
|
|
445
|
+
|
|
446
|
+
// Highlights.
|
|
447
|
+
const halfVector = normalize(lightDirection.add(viewDirection)).toVar('toonHalfVector');
|
|
448
|
+
let specular = stylizedSpecular({
|
|
449
|
+
halfVector,
|
|
450
|
+
intensity: blend('specularIntensity'),
|
|
451
|
+
normal: lightingNormal,
|
|
452
|
+
size: blend('specularSize'),
|
|
453
|
+
softness: blend('specularSoftness'),
|
|
454
|
+
threshold: blend('specularThreshold'),
|
|
455
|
+
});
|
|
456
|
+
if (features.specularMask) {
|
|
457
|
+
const mask = dot(texture(features.specularMask), u.specularMaskChannel);
|
|
458
|
+
specular = specular.mul(mix(float(1), mask, u.specularMaskStrength));
|
|
459
|
+
}
|
|
460
|
+
if (features.maps?.roughnessMap) {
|
|
461
|
+
specular = specular.mul(mix(float(1), float(1).sub(texture(features.maps.roughnessMap).y), u.roughnessStrength));
|
|
462
|
+
}
|
|
463
|
+
const metalness = (features.maps?.metalnessMap ? texture(features.maps.metalnessMap).z : u.sourceMetalness)
|
|
464
|
+
.mul(u.metalnessStrength).toVar('toonMetalness');
|
|
465
|
+
let specularColor = mix(vec3(1), albedo.div(max(maxComponent(albedo), 1e-3)), metalness);
|
|
466
|
+
if (features.maps?.specularColorMap) {
|
|
467
|
+
specularColor = specularColor.mul(mix(vec3(1), texture(features.maps.specularColorMap).rgb, u.specularColorStrength));
|
|
468
|
+
}
|
|
469
|
+
const specularLight = light.mul(specularColor).mul(specular).mul(mix(u.specularInShadow, float(1), litAmount)).toVar('toonSpecular');
|
|
470
|
+
color.addAssign(specularLight);
|
|
471
|
+
|
|
472
|
+
const ring = float(0).toVar('toonHairRing');
|
|
473
|
+
if (mayBeHair) {
|
|
474
|
+
ring.assign(hairRing(u, { head, normal: lightingNormal, viewDirection, worldPosition })
|
|
475
|
+
.mul(u.hairRingIntensity).mul(hairWeight).mul(state.headReady)
|
|
476
|
+
.mul(mix(u.hairRingShadowFloor, float(1), litAmount)));
|
|
477
|
+
const ringColor = light.mul(toLinear(mix(toDisplay(albedo), vec3(1), 0.62)));
|
|
478
|
+
color.assign(mix(color, ringColor, clamp(ring, 0, 1)));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (features.bake?.detail === 'sheer' && bake) {
|
|
482
|
+
const streak = sheerStreak(u, lightingNormal, halfVector, bake.w);
|
|
483
|
+
color.addAssign(u.sheerColor.mul(light).mul(streak).mul(mix(u.highlightShadowFloor, float(1), litAmount)));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const matcap = vec3(0).toVar('toonMatcap');
|
|
487
|
+
if (features.maps?.matcapMap) {
|
|
488
|
+
const metalWeight = role === 'metal' ? float(1) : max(metalness, 0.35);
|
|
489
|
+
matcap.assign(texture(features.maps.matcapMap, matcapUV(lightingNormal, viewPosition)).rgb);
|
|
490
|
+
color.addAssign(matcap.mul(albedo).mul(light).mul(u.matcapStrength).mul(metalWeight));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Rim.
|
|
494
|
+
const rimNodes = rimAmount(u, state, {
|
|
495
|
+
lightDirection,
|
|
496
|
+
normal: lightingNormal,
|
|
497
|
+
normalView,
|
|
498
|
+
silhouette: blend('rimSilhouette'),
|
|
499
|
+
viewDirection,
|
|
500
|
+
viewPosition,
|
|
501
|
+
});
|
|
502
|
+
const rimColor = light.mul(u.rimTint).mul(mix(vec3(1), albedo, u.rimAlbedoMix))
|
|
503
|
+
.mul(blend('rimIntensity')).mul(mix(u.rimInShadow, float(1), litAmount));
|
|
504
|
+
const rim = rimColor.mul(rimNodes.rim).toVar('toonRim');
|
|
505
|
+
color.addAssign(rim);
|
|
506
|
+
|
|
507
|
+
const emission = vec3(0).toVar('toonEmission');
|
|
508
|
+
if (features.emissive) {
|
|
509
|
+
let emitted = u.emissiveColor.mul(u.emissiveTint).mul(u.emissiveStrength);
|
|
510
|
+
if (features.maps?.emissiveMap) emitted = emitted.mul(texture(features.maps.emissiveMap).rgb);
|
|
511
|
+
emission.assign(emitted);
|
|
512
|
+
color.addAssign(emission);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (features.furLayer) color.mulAssign(mix(float(1).sub(u.shellRootShade), float(1), features.furLayer));
|
|
516
|
+
|
|
517
|
+
// Debug views (a uniform write selects one).
|
|
518
|
+
const result = color.toVar('toonResult');
|
|
519
|
+
If(u.debugMode.greaterThan(0.5), () => {
|
|
520
|
+
const roles = createRolesChunk({ flags: { isOutlinePass: false }, u: { materialRole: u.materialRole }, w: weights });
|
|
521
|
+
const id = CHARACTER_DEBUG_VIEW_IDS;
|
|
522
|
+
const views = [
|
|
523
|
+
[id.albedo, albedo],
|
|
524
|
+
[id.sourceAlbedo, source],
|
|
525
|
+
[id.litAmount, vec3(litAmount)],
|
|
526
|
+
[id.terminator, vec3(lit)],
|
|
527
|
+
[id.sceneShadow, vec3(sceneRaw)],
|
|
528
|
+
[id.characterShadow, vec3(characterRaw)],
|
|
529
|
+
[id.screenSpaceShadow, vec3(screenRaw)],
|
|
530
|
+
[id.faceMap, mix(vec3(0.5, 0.5, 0.55), vec3(faceMapLit), faceMapAmount)],
|
|
531
|
+
[id.shadowColor, shadowColor.mul(light)],
|
|
532
|
+
[id.lightColor, light],
|
|
533
|
+
[id.rim, rim],
|
|
534
|
+
[id.depthRim, vec3(rimNodes.depthRim)],
|
|
535
|
+
[id.specular, specularLight],
|
|
536
|
+
[id.hairHighlight, vec3(ring)],
|
|
537
|
+
[id.roles, roles.debugMaterialRoleColor()],
|
|
538
|
+
[id.alpha, vec3(alpha)],
|
|
539
|
+
[id.lightingMap, vec3(bias.add(0.5))],
|
|
540
|
+
[id.normalMap, lightingNormal.mul(0.5).add(0.5)],
|
|
541
|
+
[id.ao, vec3(occlusionVar)],
|
|
542
|
+
[id.emissive, emission],
|
|
543
|
+
[id.matcap, matcap],
|
|
544
|
+
];
|
|
545
|
+
for (const [value, node] of views) {
|
|
546
|
+
If(u.debugMode.equal(value), () => {
|
|
547
|
+
result.assign(node);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
return vec4(result, alpha);
|
|
552
|
+
})();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// Outline hull.
|
|
557
|
+
|
|
558
|
+
function vertexBlend(u, features) {
|
|
559
|
+
return createBlend(u, roleWeights(features, { vertex: true }));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// View-space displacement of the hull: outward along the (baked smooth)
|
|
563
|
+
// geometry normal by the role's width, corrected to a constant on-screen
|
|
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.
|
|
567
|
+
function outlineViewOffset(u, features) {
|
|
568
|
+
return (positionViewBase) => {
|
|
569
|
+
const blend = vertexBlend(u, features);
|
|
570
|
+
const weights = roleWeights(features, { vertex: true });
|
|
571
|
+
const faceWeight = roleWeightOf(features.role, features, weights, 'face');
|
|
572
|
+
const normalView = normalize(transformNormalToView(normalLocal));
|
|
573
|
+
const orthographic = cameraProjectionMatrix.element(3).w.equal(1);
|
|
574
|
+
const scaleY = cameraProjectionMatrix.element(1).y;
|
|
575
|
+
const distance = positionViewBase.z.negate().max(1e-3);
|
|
576
|
+
const units = select(orthographic, float(1).div(scaleY), min(distance, u.outlineFadeDistance).div(scaleY));
|
|
577
|
+
const correction = mix(float(1), units.div(max(u.outlineReferenceUnits, 1e-4)), u.outlineScreenSpace);
|
|
578
|
+
let width = blend('outlineWidth').mul(correction);
|
|
579
|
+
if (features.outlineWidthMap) {
|
|
580
|
+
width = width.mul(dot(texture(features.outlineWidthMap, uv(0)).level(0), u.outlineWidthMapChannel));
|
|
581
|
+
}
|
|
582
|
+
if (features.vertexColorWidth) {
|
|
583
|
+
const colorValue = attribute('color');
|
|
584
|
+
const color = vec4(colorValue.xyz, features.vertexColorAlpha ? colorValue.w : float(1));
|
|
585
|
+
const selector = u.outlineVertexColorChannel;
|
|
586
|
+
const selected = dot(color, selector);
|
|
587
|
+
width = width.mul(select(dot(selector, vec4(1)).greaterThan(0.5), selected, float(1)));
|
|
588
|
+
}
|
|
589
|
+
width = min(width, u.outlineMaxWidth);
|
|
590
|
+
const away = select(orthographic, vec3(0, 0, -1), normalize(positionViewBase));
|
|
591
|
+
return normalView.mul(width).add(away.mul(blend('outlineDepthPush').add(u.outlineFaceDepthPush.mul(faceWeight))));
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Ink: the lit colour under the line times the role's ink in display space,
|
|
596
|
+
// then saturation raised and hue stepped (scaled by the fill's chroma so grey
|
|
597
|
+
// and black lines stay neutral), darkened toward the shadow side by the
|
|
598
|
+
// lighting mix and clamped to the role's brightness range.
|
|
599
|
+
function outlineColorNode(u, features, state) {
|
|
600
|
+
return Fn(() => {
|
|
601
|
+
const geometryNormal = normalWorldGeometry.toVar('toonHullNormal');
|
|
602
|
+
if (features.dither) ditherDiscard(u);
|
|
603
|
+
// A cutout hull cannot use early depth rejection; most of it lies behind
|
|
604
|
+
// the body, so drop it before shading the ink.
|
|
605
|
+
if (features.cutout) hiddenSurfaceDiscard(state, positionView);
|
|
606
|
+
const weights = roleWeights(features);
|
|
607
|
+
const blend = createBlend(u, weights);
|
|
608
|
+
const { alpha, albedo } = albedoNodes(u, features);
|
|
609
|
+
const { direction: lightDirection } = shadingDirection(u);
|
|
610
|
+
const { light } = characterLight(u, geometryNormal);
|
|
611
|
+
const terminator = blend('terminator');
|
|
612
|
+
const softness = max(blend('softness'), 0.02);
|
|
613
|
+
const lit = smoothstep(terminator.sub(softness), terminator.add(softness), dot(geometryNormal, lightDirection));
|
|
614
|
+
const fill = albedo.mul(light);
|
|
615
|
+
const chromaWeight = smoothstep(0.02, 0.12, displayChroma(fill));
|
|
616
|
+
const inked = rgbToHsv(clamp(toDisplay(fill).mul(blend('ink')), 0, 1)).toVar('toonInkHsv');
|
|
617
|
+
inked.y.assign(clamp(inked.y.add(blend('inkSaturation').mul(chromaWeight)), 0, 1));
|
|
618
|
+
inked.x.assign(fract(inked.x.add(u.inkHueShift.mul(chromaWeight)).add(1)));
|
|
619
|
+
let line = toLinear(hsvToRgb(inked)).mul(mix(float(1), lit, blend('outlineLightingMix')));
|
|
620
|
+
if (features.outlineColorOverride) {
|
|
621
|
+
const authored = u.outlineColorOverride.xyz.mul(mix(vec3(1), light, u.outlineOverrideLightingMix));
|
|
622
|
+
line = mix(line, authored, u.outlineColorOverride.w);
|
|
623
|
+
}
|
|
624
|
+
const display = toDisplay(line).toVar('toonInkDisplay');
|
|
625
|
+
const brightness = maxComponent(display);
|
|
626
|
+
const target = clamp(brightness, blend('outlineMinBrightness'), blend('outlineMaxBrightness'));
|
|
627
|
+
const result = toLinear(display.mul(target.div(max(brightness, 1e-4)))).toVar('toonInk');
|
|
628
|
+
If(u.debugMode.greaterThan(0.5), () => {
|
|
629
|
+
result.assign(vec3(0));
|
|
630
|
+
});
|
|
631
|
+
return vec4(result, alpha);
|
|
632
|
+
})();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Fur shells: strands thin toward the tip (a per-cell hash against the
|
|
636
|
+
// shell's layer), displaced along the normal and sagging with gravity.
|
|
637
|
+
function furDiscard(u, layer) {
|
|
638
|
+
const cell = uv(0).mul(u.shellDensity.mul(100)).floor();
|
|
639
|
+
const noise = hash(cell.x.add(cell.y.mul(4099)));
|
|
640
|
+
If(noise.lessThan(float(layer).add(u.shellRootOffset)), () => {
|
|
641
|
+
Discard();
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function furViewOffset(u, layer) {
|
|
646
|
+
return () => {
|
|
647
|
+
const normalView = normalize(transformNormalToView(normalLocal));
|
|
648
|
+
const down = cameraViewMatrix.mul(vec4(0, -1, 0, 0)).xyz;
|
|
649
|
+
return normalView.mul(u.shellLength.mul(layer)).add(down.mul(u.shellLength.mul(u.shellSag).mul(layer * layer)));
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Brows, lashes and eye lines draw over the bangs: their depth is pulled
|
|
654
|
+
// toward the camera while the face points at it.
|
|
655
|
+
function overHairViewOffset(u, state) {
|
|
656
|
+
return (positionViewBase) => {
|
|
657
|
+
const toCamera = normalize(cameraPosition.sub(state.headPosition));
|
|
658
|
+
const facing = smoothstep(0.25, 0.65, dot(state.headForward, toCamera)).mul(state.headReady);
|
|
659
|
+
const orthographic = cameraProjectionMatrix.element(3).w.equal(1);
|
|
660
|
+
const toward = select(orthographic, vec3(0, 0, 1), normalize(positionViewBase).negate());
|
|
661
|
+
return toward.mul(u.overHairDepth.mul(facing));
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// Pass variants.
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Depth-as-colour variant for the depth prepass and the character shadow
|
|
670
|
+
* map: r = window depth, offset by 2 on face pixels (so face receivers can
|
|
671
|
+
* ignore face occluders), alpha-tested like the colour pass.
|
|
672
|
+
*/
|
|
673
|
+
export function createCharacterDepthVariant(material) {
|
|
674
|
+
const features = material.userData.toonFeatures;
|
|
675
|
+
const variant = new PassBasicNodeMaterial({ side: material.side });
|
|
676
|
+
variant.isShadowPassMaterial = true;
|
|
677
|
+
const orthographic = cameraProjectionMatrix.element(3).w.equal(1);
|
|
678
|
+
const depth = select(
|
|
679
|
+
orthographic,
|
|
680
|
+
viewZToOrthographicDepth(positionView.z, cameraNear, cameraFar),
|
|
681
|
+
viewZToPerspectiveDepth(positionView.z, cameraNear, cameraFar),
|
|
682
|
+
);
|
|
683
|
+
const weights = roleWeights(features);
|
|
684
|
+
const faceWeight = features.role === 'eye' ? float(1) : roleWeightOf(features.role, features, weights, 'face');
|
|
685
|
+
// Face (and eye) surfaces are offset so face receivers can tell and
|
|
686
|
+
// ignore them (castShadows.js decodeStoredDepth).
|
|
687
|
+
const encoded = select(faceWeight.greaterThan(0.5), depth.add(FACE_DEPTH_OFFSET), depth);
|
|
688
|
+
const alpha = features.baseMap ? texture(features.baseMap).a : float(1);
|
|
689
|
+
variant.colorNode = vec4(encoded, 0, 0, alpha);
|
|
690
|
+
if (material.alphaTest > 0) variant.alphaTest = material.alphaTest;
|
|
691
|
+
variant.name = `${material.name || 'ToonCharacter'}:Depth`;
|
|
692
|
+
return variant;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** White coverage mask variant (character-aware post effects). */
|
|
696
|
+
export function createCharacterMaskVariant(material) {
|
|
697
|
+
const features = material.userData.toonFeatures;
|
|
698
|
+
const variant = new PassBasicNodeMaterial({ side: material.side });
|
|
699
|
+
variant.isShadowPassMaterial = true;
|
|
700
|
+
const alpha = features.baseMap ? texture(features.baseMap).a : float(1);
|
|
701
|
+
variant.colorNode = vec4(1, 1, 1, alpha);
|
|
702
|
+
if (material.alphaTest > 0) variant.alphaTest = material.alphaTest;
|
|
703
|
+
variant.name = `${material.name || 'ToonCharacter'}:Mask`;
|
|
704
|
+
return variant;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// Assembly.
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Builds a character material.
|
|
712
|
+
*
|
|
713
|
+
* - `params`: uniform values (characterParameters.js plus per-material
|
|
714
|
+
* inputs such as `baseColor`); every key becomes a uniform.
|
|
715
|
+
* - `features`: graph-build gates and textures — `role`, `outline`,
|
|
716
|
+
* `baseMap`, `roleWeights` ('attribute' | 'mask'), `roleMaskMap`,
|
|
717
|
+
* `bake` ({ any, faceUv, occlusion, detail }), `faceMap`,
|
|
718
|
+
* `faceMapCoords` ('bake' | 0 | 1), `noseMark`, `lightingMap`,
|
|
719
|
+
* `lightingMapUv`, `shadeOverride`, `shadeMap`, `toneOverride`,
|
|
720
|
+
* `rampMap`, `rampMapDecoded`, `specularMask`, `maps` (normalMap, aoMap,
|
|
721
|
+
* emissiveMap, matcapMap, detailMap, roughnessMap, metalnessMap,
|
|
722
|
+
* specularColorMap), `emissive`, `sticker` ({ map, uvChannel }),
|
|
723
|
+
* `vertexColors`, `outlineWidthMap`, `vertexColorWidth`,
|
|
724
|
+
* `outlineColorOverride`, `overHairFeature`, `materialRole`, `cutout`
|
|
725
|
+
* (alpha-tested), `dither` (screen-door fade compiled in).
|
|
726
|
+
* - `state`: the character's shared state (characterState.js).
|
|
727
|
+
*/
|
|
728
|
+
export function createToonCharacterMaterial({ features, params, state, side = THREE.DoubleSide }) {
|
|
729
|
+
const material = new ToonCharacterNodeMaterial();
|
|
730
|
+
const uniforms = {};
|
|
731
|
+
for (const [name, value] of Object.entries({ ...MATERIAL_INPUT_DEFAULTS, ...params })) uniforms[name] = uniformFor(value);
|
|
732
|
+
uniforms.debugMode = uniform(0);
|
|
733
|
+
uniforms.materialRole = uniform(features.materialRole ?? 0, 'int');
|
|
734
|
+
material.uniforms = uniforms;
|
|
735
|
+
material.toneMapped = false;
|
|
736
|
+
material.userData.toonFeatures = features;
|
|
737
|
+
bindMaterialToCharacterState(material, state);
|
|
738
|
+
|
|
739
|
+
const build = () => {
|
|
740
|
+
const current = material.userData.toonFeatures;
|
|
741
|
+
material.colorNode = current.outline
|
|
742
|
+
? outlineColorNode(uniforms, current, state)
|
|
743
|
+
: surfaceColorNode(uniforms, current, state);
|
|
744
|
+
};
|
|
745
|
+
if (features.outline) {
|
|
746
|
+
material.side = THREE.BackSide;
|
|
747
|
+
material.viewOffset = outlineViewOffset(uniforms, features);
|
|
748
|
+
} else {
|
|
749
|
+
material.side = side;
|
|
750
|
+
if (features.furLayer) material.viewOffset = furViewOffset(uniforms, features.furLayer);
|
|
751
|
+
else if (features.overHairFeature) material.viewOffset = overHairViewOffset(uniforms, state);
|
|
752
|
+
}
|
|
753
|
+
build();
|
|
754
|
+
// Screen-door fading is compiled in on first use (a shader that may
|
|
755
|
+
// discard loses early depth rejection, so opaque characters go without).
|
|
756
|
+
material.userData.enableDither = () => {
|
|
757
|
+
if (material.userData.toonFeatures.dither) return false;
|
|
758
|
+
material.userData.toonFeatures = { ...material.userData.toonFeatures, dither: true };
|
|
759
|
+
build();
|
|
760
|
+
material.needsUpdate = true;
|
|
761
|
+
return true;
|
|
762
|
+
};
|
|
763
|
+
material.userData.createDepthColorVariant = () => createCharacterDepthVariant(material);
|
|
764
|
+
material.userData.createMaskVariant = () => createCharacterMaskVariant(material);
|
|
765
|
+
return material;
|
|
766
|
+
}
|