@call-me-sensei/toonlab 0.1.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.
Files changed (146) hide show
  1. package/ATTRIBUTION.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/package.json +97 -0
  5. package/src/character/characterRig.js +353 -0
  6. package/src/character/freestyleSwimClip.js +541 -0
  7. package/src/character/index.js +4 -0
  8. package/src/character/modelLoader.js +433 -0
  9. package/src/core/materialRoles.js +438 -0
  10. package/src/core/presetDocuments.js +110 -0
  11. package/src/core/shaderBackend.js +24 -0
  12. package/src/debrisgen/debrisFields.js +544 -0
  13. package/src/debrisgen/debrisGenerator.js +1929 -0
  14. package/src/debrisgen/debrisPalettes.js +71 -0
  15. package/src/debrisgen/debrisPhysics.js +198 -0
  16. package/src/debrisgen/debrisPresets.js +155 -0
  17. package/src/debrisgen/debrisSettings.js +333 -0
  18. package/src/debrisgen/debrisTextures.js +380 -0
  19. package/src/debrisgen/index.js +5 -0
  20. package/src/debug/fieldValues.js +95 -0
  21. package/src/debug/index.js +7 -0
  22. package/src/debug/settingsPanel.js +170 -0
  23. package/src/environment/environmentAmbientProbe.js +160 -0
  24. package/src/environment/environmentMaterialAdapter.js +336 -0
  25. package/src/environment/environmentMaterialClassifier.js +178 -0
  26. package/src/environment/environmentPlanarReflection.js +201 -0
  27. package/src/environment/environmentPresets.js +443 -0
  28. package/src/environment/environmentRigs.js +684 -0
  29. package/src/environment/environmentSettings.js +504 -0
  30. package/src/environment/environmentShaderMaterials.js +318 -0
  31. package/src/environment/environmentSunShadowPass.js +268 -0
  32. package/src/environment/environmentTextureResolver.js +161 -0
  33. package/src/environment/environmentTimeOfDay.js +167 -0
  34. package/src/environment/environmentVertexAo.js +188 -0
  35. package/src/environment/index.js +8 -0
  36. package/src/environment/scanAssetStylize.js +103 -0
  37. package/src/index.js +21 -0
  38. package/src/loaders/index.js +1 -0
  39. package/src/post/index.js +2 -0
  40. package/src/post/postProcessing.js +1087 -0
  41. package/src/rockgen/export/glbExport.js +177 -0
  42. package/src/rockgen/heightfield/heightfieldErosion.js +7 -0
  43. package/src/rockgen/heightfield/heightfieldPatch.js +183 -0
  44. package/src/rockgen/heightfield/stylizedErosionSim.js +356 -0
  45. package/src/rockgen/index.js +14 -0
  46. package/src/rockgen/mesh/meshAttributes.js +374 -0
  47. package/src/rockgen/mesh/meshDocument.js +158 -0
  48. package/src/rockgen/mesh/surfaceNets.js +329 -0
  49. package/src/rockgen/noise/cellularNoise3.js +92 -0
  50. package/src/rockgen/noise/prng.js +62 -0
  51. package/src/rockgen/noise/simplexNoise3.js +89 -0
  52. package/src/rockgen/noise/valueNoise3.js +87 -0
  53. package/src/rockgen/rockDocument.js +255 -0
  54. package/src/rockgen/rockHelpers.js +8 -0
  55. package/src/rockgen/rockgenPresets.js +559 -0
  56. package/src/rockgen/rockgenSettings.js +756 -0
  57. package/src/rockgen/sdf/fieldCompiler.js +544 -0
  58. package/src/rockgen/sdf/sculptEdits.js +66 -0
  59. package/src/rockgen/sdf/sdfModifiers.js +45 -0
  60. package/src/rockgen/sdf/sdfOps.js +37 -0
  61. package/src/rockgen/sdf/sdfPrimitives.js +88 -0
  62. package/src/shaders-tsl/anime.js +963 -0
  63. package/src/shaders-tsl/chunks/character-color.js +64 -0
  64. package/src/shaders-tsl/chunks/character-highlights.js +174 -0
  65. package/src/shaders-tsl/chunks/character-lighting.js +309 -0
  66. package/src/shaders-tsl/chunks/character-material-maps.js +146 -0
  67. package/src/shaders-tsl/chunks/character-roles.js +52 -0
  68. package/src/shaders-tsl/chunks/character-scene-lights.js +270 -0
  69. package/src/shaders-tsl/chunks/character-shadow-color.js +61 -0
  70. package/src/shaders-tsl/chunks/character-skinning.js +140 -0
  71. package/src/shaders-tsl/chunks/environment-color.js +47 -0
  72. package/src/shaders-tsl/chunks/environment-debug.js +79 -0
  73. package/src/shaders-tsl/chunks/environment-lighting.js +260 -0
  74. package/src/shaders-tsl/chunks/environment-sun-shadow.js +87 -0
  75. package/src/shaders-tsl/chunks/foliage-fog.js +60 -0
  76. package/src/shaders-tsl/chunks/pass-depth-color.js +89 -0
  77. package/src/shaders-tsl/chunks/stylized-cloud-shadow.js +70 -0
  78. package/src/shaders-tsl/chunks/water-color.js +190 -0
  79. package/src/shaders-tsl/chunks/water-common.js +124 -0
  80. package/src/shaders-tsl/chunks/water-foam.js +93 -0
  81. package/src/shaders-tsl/chunks/water-lighting.js +116 -0
  82. package/src/shaders-tsl/chunks/water-ripple.js +64 -0
  83. package/src/shaders-tsl/chunks/water-waves.js +100 -0
  84. package/src/shaders-tsl/environment-ao-overlay.js +60 -0
  85. package/src/shaders-tsl/environment.js +680 -0
  86. package/src/shaders-tsl/flower.js +267 -0
  87. package/src/shaders-tsl/grass.js +193 -0
  88. package/src/shaders-tsl/post-composite.js +461 -0
  89. package/src/shaders-tsl/sky.js +175 -0
  90. package/src/shaders-tsl/tree-leaf.js +264 -0
  91. package/src/shaders-tsl/water-breaker.js +423 -0
  92. package/src/shaders-tsl/water-kelp.js +171 -0
  93. package/src/shaders-tsl/water-rain.js +107 -0
  94. package/src/shaders-tsl/water-simulation.js +140 -0
  95. package/src/shaders-tsl/water-splash.js +298 -0
  96. package/src/shaders-tsl/water.js +509 -0
  97. package/src/sky/index.js +2 -0
  98. package/src/sky/stylizedSky.js +341 -0
  99. package/src/toon/characterRenderPasses.js +646 -0
  100. package/src/toon/index.js +4 -0
  101. package/src/toon/settings/alphaSettings.js +170 -0
  102. package/src/toon/settings/averageShadowSettings.js +181 -0
  103. package/src/toon/settings/baseTextureSettings.js +133 -0
  104. package/src/toon/settings/celShadeSettings.js +96 -0
  105. package/src/toon/settings/contactShadowSettings.js +114 -0
  106. package/src/toon/settings/eyeHighlightSettings.js +187 -0
  107. package/src/toon/settings/faceLightingSettings.js +135 -0
  108. package/src/toon/settings/furSettings.js +112 -0
  109. package/src/toon/settings/glitterSettings.js +102 -0
  110. package/src/toon/settings/hairHighlightSettings.js +325 -0
  111. package/src/toon/settings/indirectLightSettings.js +244 -0
  112. package/src/toon/settings/localLightSettings.js +176 -0
  113. package/src/toon/settings/materialMapSettings.js +299 -0
  114. package/src/toon/settings/outlineSettings.js +263 -0
  115. package/src/toon/settings/perspectiveRemovalSettings.js +60 -0
  116. package/src/toon/settings/rimLightSettings.js +268 -0
  117. package/src/toon/settings/sceneShadowSettings.js +147 -0
  118. package/src/toon/settings/selfShadowSettings.js +221 -0
  119. package/src/toon/settings/shadowColorSettings.js +238 -0
  120. package/src/toon/settings/skinToneSettings.js +150 -0
  121. package/src/toon/settings/specularSettings.js +314 -0
  122. package/src/toon/settings/stickerSettings.js +102 -0
  123. package/src/toon/toonMaterialAdapter.js +2235 -0
  124. package/src/toon/toonSettings.js +860 -0
  125. package/src/vegetation/flowerSpecies.js +260 -0
  126. package/src/vegetation/index.js +10 -0
  127. package/src/vegetation/stylizedBush.js +136 -0
  128. package/src/vegetation/stylizedFlower.js +213 -0
  129. package/src/vegetation/stylizedFlowers.js +297 -0
  130. package/src/vegetation/stylizedGrass.js +491 -0
  131. package/src/vegetation/stylizedTree.js +2449 -0
  132. package/src/vegetation/stylizedTreeFoliage.js +691 -0
  133. package/src/vegetation/treeExport.js +300 -0
  134. package/src/vegetation/treeRecipe.js +647 -0
  135. package/src/water/index.js +2 -0
  136. package/src/water/water.js +20 -0
  137. package/src/water/waterBreakerSystem.js +591 -0
  138. package/src/water/waterInteraction.js +193 -0
  139. package/src/water/waterMaterial.js +263 -0
  140. package/src/water/waterRain.js +136 -0
  141. package/src/water/waterRippleSimulation.js +227 -0
  142. package/src/water/waterScenePasses.js +415 -0
  143. package/src/water/waterSettings.js +1138 -0
  144. package/src/water/waterSplashSystem.js +251 -0
  145. package/src/water/waterSurface.js +475 -0
  146. package/src/water/waterVegetation.js +83 -0
@@ -0,0 +1,150 @@
1
+ import * as THREE from 'three';
2
+
3
+ export const DEFAULT_SKIN_TONE_SETTINGS = Object.freeze({
4
+ enabled: true,
5
+ faceMaxDirectLight: 100,
6
+ faceMinimumIndirectLight: 0.35,
7
+ faceShadowBrightness: 1.0,
8
+ faceShadowSaturation: 1.0,
9
+ faceShadowTint: [1.0, 0.92, 0.9],
10
+ faceShadowTintStrength: 1.0,
11
+ skinMaxDirectLight: 100,
12
+ skinMinimumIndirectLight: 0.35,
13
+ skinShadowBrightness: 0.92,
14
+ skinShadowSaturation: 1.0,
15
+ skinShadowTint: [1.0, 0.76, 0.74],
16
+ skinShadowTintStrength: 1.0,
17
+ });
18
+
19
+ function firstDefined(source, keys) {
20
+ for (const key of keys) {
21
+ if (source?.[key] !== undefined) return source[key];
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ function numberOption(value, fallback, { min = -Infinity, max = Infinity } = {}) {
27
+ const nextValue = Number(value);
28
+ if (!Number.isFinite(nextValue)) return fallback;
29
+ return THREE.MathUtils.clamp(nextValue, min, max);
30
+ }
31
+
32
+ function colorOption(value, fallback) {
33
+ if (value?.isColor) return value.clone();
34
+
35
+ if (Array.isArray(value) && value.length >= 3) {
36
+ return new THREE.Color(
37
+ numberOption(value[0], fallback[0] ?? fallback.r ?? 1),
38
+ numberOption(value[1], fallback[1] ?? fallback.g ?? 1),
39
+ numberOption(value[2], fallback[2] ?? fallback.b ?? 1),
40
+ );
41
+ }
42
+
43
+ if (typeof value === 'object' && value !== null) {
44
+ const r = numberOption(value.r, fallback[0] ?? fallback.r ?? 1);
45
+ const g = numberOption(value.g, fallback[1] ?? fallback.g ?? 1);
46
+ const b = numberOption(value.b, fallback[2] ?? fallback.b ?? 1);
47
+ return new THREE.Color(r, g, b);
48
+ }
49
+
50
+ if (typeof value === 'string' || typeof value === 'number') {
51
+ try {
52
+ return new THREE.Color(value);
53
+ } catch {
54
+ // Fall through to the normalized fallback below.
55
+ }
56
+ }
57
+
58
+ return Array.isArray(fallback)
59
+ ? new THREE.Color(fallback[0], fallback[1], fallback[2])
60
+ : fallback.clone();
61
+ }
62
+
63
+ export function createSkinToneSettings(options = null) {
64
+ const source = options || {};
65
+ const enabled = source.enabled !== false;
66
+ const skinShadowTintStrength = enabled
67
+ ? numberOption(
68
+ firstDefined(source, ['skinShadowTintStrength', 'skinShadowStrength', 'skinTintStrength']),
69
+ DEFAULT_SKIN_TONE_SETTINGS.skinShadowTintStrength,
70
+ { min: 0, max: 1 },
71
+ )
72
+ : 0;
73
+ const faceShadowTintStrength = enabled
74
+ ? numberOption(
75
+ firstDefined(source, ['faceShadowTintStrength', 'faceShadowStrength', 'faceTintStrength']),
76
+ DEFAULT_SKIN_TONE_SETTINGS.faceShadowTintStrength,
77
+ { min: 0, max: 1 },
78
+ )
79
+ : 0;
80
+
81
+ return {
82
+ enabled,
83
+ faceMaxDirectLight: enabled
84
+ ? numberOption(
85
+ firstDefined(source, ['faceMaxDirectLight', 'faceDirectLightMax', 'maxFaceDirectLight']),
86
+ DEFAULT_SKIN_TONE_SETTINGS.faceMaxDirectLight,
87
+ { min: 0.05, max: 100 },
88
+ )
89
+ : DEFAULT_SKIN_TONE_SETTINGS.faceMaxDirectLight,
90
+ faceMinimumIndirectLight: enabled
91
+ ? numberOption(
92
+ firstDefined(source, ['faceMinimumIndirectLight', 'faceMinIndirectLight', 'faceIndirectLightMin']),
93
+ DEFAULT_SKIN_TONE_SETTINGS.faceMinimumIndirectLight,
94
+ { min: 0, max: 1 },
95
+ )
96
+ : DEFAULT_SKIN_TONE_SETTINGS.faceMinimumIndirectLight,
97
+ faceShadowBrightness: enabled
98
+ ? numberOption(
99
+ firstDefined(source, ['faceShadowBrightness', 'faceShadowValue', 'faceShadowLightness']),
100
+ DEFAULT_SKIN_TONE_SETTINGS.faceShadowBrightness,
101
+ { min: 0, max: 4 },
102
+ )
103
+ : DEFAULT_SKIN_TONE_SETTINGS.faceShadowBrightness,
104
+ faceShadowSaturation: enabled
105
+ ? numberOption(
106
+ firstDefined(source, ['faceShadowSaturation', 'faceShadowSat', 'shadowSaturation']),
107
+ DEFAULT_SKIN_TONE_SETTINGS.faceShadowSaturation,
108
+ { min: 0, max: 4 },
109
+ )
110
+ : DEFAULT_SKIN_TONE_SETTINGS.faceShadowSaturation,
111
+ faceShadowTint: colorOption(
112
+ firstDefined(source, ['faceShadowTint', 'faceShadowColor', 'faceTint']),
113
+ DEFAULT_SKIN_TONE_SETTINGS.faceShadowTint,
114
+ ),
115
+ faceShadowTintStrength,
116
+ skinMaxDirectLight: enabled
117
+ ? numberOption(
118
+ firstDefined(source, ['skinMaxDirectLight', 'skinDirectLightMax', 'maxSkinDirectLight']),
119
+ DEFAULT_SKIN_TONE_SETTINGS.skinMaxDirectLight,
120
+ { min: 0.05, max: 100 },
121
+ )
122
+ : DEFAULT_SKIN_TONE_SETTINGS.skinMaxDirectLight,
123
+ skinMinimumIndirectLight: enabled
124
+ ? numberOption(
125
+ firstDefined(source, ['skinMinimumIndirectLight', 'skinMinIndirectLight', 'skinIndirectLightMin']),
126
+ DEFAULT_SKIN_TONE_SETTINGS.skinMinimumIndirectLight,
127
+ { min: 0, max: 1 },
128
+ )
129
+ : DEFAULT_SKIN_TONE_SETTINGS.skinMinimumIndirectLight,
130
+ skinShadowBrightness: enabled
131
+ ? numberOption(
132
+ firstDefined(source, ['skinShadowBrightness', 'skinShadowValue', 'skinShadowLightness']),
133
+ DEFAULT_SKIN_TONE_SETTINGS.skinShadowBrightness,
134
+ { min: 0, max: 4 },
135
+ )
136
+ : DEFAULT_SKIN_TONE_SETTINGS.skinShadowBrightness,
137
+ skinShadowSaturation: enabled
138
+ ? numberOption(
139
+ firstDefined(source, ['skinShadowSaturation', 'skinShadowSat', 'shadowSaturation']),
140
+ DEFAULT_SKIN_TONE_SETTINGS.skinShadowSaturation,
141
+ { min: 0, max: 4 },
142
+ )
143
+ : DEFAULT_SKIN_TONE_SETTINGS.skinShadowSaturation,
144
+ skinShadowTint: colorOption(
145
+ firstDefined(source, ['skinShadowTint', 'skinShadowColor', 'skinTint']),
146
+ DEFAULT_SKIN_TONE_SETTINGS.skinShadowTint,
147
+ ),
148
+ skinShadowTintStrength,
149
+ };
150
+ }
@@ -0,0 +1,314 @@
1
+ import * as THREE from 'three';
2
+
3
+ export const SPECULAR_MASK_CHANNELS = Object.freeze({
4
+ r: 0,
5
+ red: 0,
6
+ x: 0,
7
+ g: 1,
8
+ green: 1,
9
+ y: 1,
10
+ b: 2,
11
+ blue: 2,
12
+ z: 2,
13
+ a: 3,
14
+ alpha: 3,
15
+ w: 3,
16
+ });
17
+
18
+ export const SPECULAR_SOURCE_MASK_MODES = Object.freeze({
19
+ off: 'off',
20
+ source: 'source',
21
+ });
22
+
23
+ // 'light': classic Blinn-style highlight from the half vector (moves with the
24
+ // light). 'view': uses the view angle instead, so the highlight stays
25
+ // anchored to the surface as lights animate — a stable, matcap-like
26
+ // response that suits eyes and metal trim.
27
+ export const SPECULAR_DIRECTION_MODES = Object.freeze({
28
+ light: 'light',
29
+ view: 'view',
30
+ });
31
+
32
+ export const SPECULAR_DIRECTION_MODE_VALUES = Object.freeze({
33
+ [SPECULAR_DIRECTION_MODES.light]: 0,
34
+ [SPECULAR_DIRECTION_MODES.view]: 1,
35
+ });
36
+
37
+ export const DEFAULT_SPECULAR_SETTINGS = Object.freeze({
38
+ defaultColor: [1, 0.96, 0.9],
39
+ defaultIntensity: 0.075,
40
+ defaultMidPoint: 0.72,
41
+ defaultPower: 56,
42
+ defaultRange: 0.12,
43
+ defaultShowInShadowArea: 0.25,
44
+ directionMode: SPECULAR_DIRECTION_MODES.light,
45
+ enabled: true,
46
+ eyeIntensity: 0.62,
47
+ eyeMidPoint: 0.35,
48
+ eyePower: 18,
49
+ eyeRange: 0.18,
50
+ eyeShowInShadowArea: 1,
51
+ faceIntensity: 0.025,
52
+ hairIntensity: 0.18,
53
+ hairPower: 40,
54
+ maskChannel: SPECULAR_MASK_CHANNELS.r,
55
+ maskMap: null,
56
+ maskStrength: 1,
57
+ metalIntensity: 0.075,
58
+ skinIntensity: 0.025,
59
+ sourceMaskMode: SPECULAR_SOURCE_MASK_MODES.off,
60
+ });
61
+
62
+ function firstDefined(source, keys) {
63
+ for (const key of keys) {
64
+ if (source?.[key] !== undefined) return source[key];
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ function numberOption(value, fallback, { min = -Infinity, max = Infinity } = {}) {
70
+ const nextValue = Number(value);
71
+ if (!Number.isFinite(nextValue)) return fallback;
72
+ return Math.min(max, Math.max(min, nextValue));
73
+ }
74
+
75
+ function colorOption(value, fallback) {
76
+ if (value?.isColor) return value.clone();
77
+
78
+ if (Array.isArray(value) && value.length >= 3) {
79
+ return new THREE.Color(
80
+ numberOption(value[0], fallback[0] ?? fallback.r ?? 1),
81
+ numberOption(value[1], fallback[1] ?? fallback.g ?? 1),
82
+ numberOption(value[2], fallback[2] ?? fallback.b ?? 1),
83
+ );
84
+ }
85
+
86
+ if (typeof value === 'object' && value !== null) {
87
+ return new THREE.Color(
88
+ numberOption(value.r ?? value.x, fallback[0] ?? fallback.r ?? 1),
89
+ numberOption(value.g ?? value.y, fallback[1] ?? fallback.g ?? 1),
90
+ numberOption(value.b ?? value.z, fallback[2] ?? fallback.b ?? 1),
91
+ );
92
+ }
93
+
94
+ if (typeof value === 'string' || typeof value === 'number') {
95
+ try {
96
+ return new THREE.Color(value);
97
+ } catch {
98
+ // Fall through to fallback.
99
+ }
100
+ }
101
+
102
+ return Array.isArray(fallback)
103
+ ? new THREE.Color(fallback[0], fallback[1], fallback[2])
104
+ : fallback.clone();
105
+ }
106
+
107
+ function enabledOption(value) {
108
+ if (typeof value === 'string') {
109
+ const normalized = value.trim().toLowerCase();
110
+ return normalized !== 'off' && normalized !== 'none' && normalized !== 'false' && normalized !== '0';
111
+ }
112
+ return value !== false;
113
+ }
114
+
115
+ function normalizeSpecularOptions(options) {
116
+ if (options === false) return { enabled: false };
117
+ if (options === true) return { enabled: true };
118
+ if (typeof options === 'string') return { enabled: enabledOption(options) };
119
+ return options || {};
120
+ }
121
+
122
+ function normalizeSourceMaskMode(value) {
123
+ if (value === true) return SPECULAR_SOURCE_MASK_MODES.source;
124
+ if (value === false) return SPECULAR_SOURCE_MASK_MODES.off;
125
+ const normalized = String(value ?? SPECULAR_SOURCE_MASK_MODES.off).trim().toLowerCase();
126
+ if (normalized === 'material' || normalized === 'map' || normalized === 'source') {
127
+ return SPECULAR_SOURCE_MASK_MODES.source;
128
+ }
129
+ return SPECULAR_SOURCE_MASK_MODES.off;
130
+ }
131
+
132
+ function normalizeMaskChannel(value) {
133
+ if (Number.isFinite(value)) return Math.min(3, Math.max(0, Math.round(value)));
134
+ const normalized = String(value ?? '').trim().toLowerCase();
135
+ return SPECULAR_MASK_CHANNELS[normalized] ?? DEFAULT_SPECULAR_SETTINGS.maskChannel;
136
+ }
137
+
138
+ function roleNumber(source, role, property, fallback, aliases = [], bounds = {}) {
139
+ const rolePrefix = role ? `${role}${property[0].toUpperCase()}${property.slice(1)}` : property;
140
+ return numberOption(firstDefined(source, [rolePrefix, ...aliases]), fallback, bounds);
141
+ }
142
+
143
+ function normalizeDirectionMode(value) {
144
+ const key = String(value ?? '')
145
+ .trim()
146
+ .replace(/[\s_-]+/g, '')
147
+ .toLowerCase();
148
+ if (['view', 'nov', 'stable', 'matcaplike', 'lv', 'lequalsv'].includes(key)) {
149
+ return SPECULAR_DIRECTION_MODES.view;
150
+ }
151
+ return SPECULAR_DIRECTION_MODES.light;
152
+ }
153
+
154
+ export function createSpecularSettings(options = null) {
155
+ const source = normalizeSpecularOptions(options);
156
+ const enabled = enabledOption(source.enabled);
157
+ const defaultIntensity = enabled
158
+ ? roleNumber(
159
+ source,
160
+ 'default',
161
+ 'intensity',
162
+ DEFAULT_SPECULAR_SETTINGS.defaultIntensity,
163
+ ['intensity', 'specularIntensity'],
164
+ { min: 0, max: 8 },
165
+ )
166
+ : 0;
167
+ const defaultMidPoint = enabled
168
+ ? roleNumber(
169
+ source,
170
+ 'default',
171
+ 'midPoint',
172
+ DEFAULT_SPECULAR_SETTINGS.defaultMidPoint,
173
+ ['midPoint', 'specularMidPoint', 'specularAreaRemapMidPoint'],
174
+ { min: 0, max: 1 },
175
+ )
176
+ : DEFAULT_SPECULAR_SETTINGS.defaultMidPoint;
177
+ const defaultRange = enabled
178
+ ? roleNumber(
179
+ source,
180
+ 'default',
181
+ 'range',
182
+ DEFAULT_SPECULAR_SETTINGS.defaultRange,
183
+ ['range', 'specularRange', 'specularAreaRemapRange'],
184
+ { min: 0.001, max: 1 },
185
+ )
186
+ : DEFAULT_SPECULAR_SETTINGS.defaultRange;
187
+ const defaultPower = enabled
188
+ ? roleNumber(
189
+ source,
190
+ 'default',
191
+ 'power',
192
+ DEFAULT_SPECULAR_SETTINGS.defaultPower,
193
+ ['power', 'specularPower', 'shininess'],
194
+ { min: 1, max: 512 },
195
+ )
196
+ : DEFAULT_SPECULAR_SETTINGS.defaultPower;
197
+ const defaultShowInShadowArea = enabled
198
+ ? roleNumber(
199
+ source,
200
+ 'default',
201
+ 'showInShadowArea',
202
+ DEFAULT_SPECULAR_SETTINGS.defaultShowInShadowArea,
203
+ ['showInShadowArea', 'specularShowInShadowArea', 'shadowVisibility'],
204
+ { min: 0, max: 1 },
205
+ )
206
+ : 0;
207
+
208
+ return {
209
+ defaultColor: colorOption(
210
+ firstDefined(source, ['color', 'specularColor', 'defaultColor']),
211
+ DEFAULT_SPECULAR_SETTINGS.defaultColor,
212
+ ),
213
+ defaultIntensity,
214
+ defaultMidPoint,
215
+ defaultPower,
216
+ defaultRange,
217
+ defaultShowInShadowArea,
218
+ directionMode: normalizeDirectionMode(
219
+ firstDefined(source, ['directionMode', 'specularMode', 'highlightMode']),
220
+ ),
221
+ enabled,
222
+ eyeIntensity: enabled
223
+ ? roleNumber(source, 'eye', 'intensity', DEFAULT_SPECULAR_SETTINGS.eyeIntensity, [], { min: 0, max: 8 })
224
+ : 0,
225
+ eyeMidPoint: enabled
226
+ ? roleNumber(source, 'eye', 'midPoint', DEFAULT_SPECULAR_SETTINGS.eyeMidPoint, [], { min: 0, max: 1 })
227
+ : DEFAULT_SPECULAR_SETTINGS.eyeMidPoint,
228
+ eyePower: enabled
229
+ ? roleNumber(source, 'eye', 'power', DEFAULT_SPECULAR_SETTINGS.eyePower, [], { min: 1, max: 512 })
230
+ : DEFAULT_SPECULAR_SETTINGS.eyePower,
231
+ eyeRange: enabled
232
+ ? roleNumber(source, 'eye', 'range', DEFAULT_SPECULAR_SETTINGS.eyeRange, [], { min: 0.001, max: 1 })
233
+ : DEFAULT_SPECULAR_SETTINGS.eyeRange,
234
+ eyeShowInShadowArea: enabled
235
+ ? roleNumber(source, 'eye', 'showInShadowArea', DEFAULT_SPECULAR_SETTINGS.eyeShowInShadowArea, [], { min: 0, max: 1 })
236
+ : 0,
237
+ faceIntensity: enabled
238
+ ? roleNumber(source, 'face', 'intensity', DEFAULT_SPECULAR_SETTINGS.faceIntensity, [], { min: 0, max: 8 })
239
+ : 0,
240
+ hairIntensity: enabled
241
+ ? roleNumber(source, 'hair', 'intensity', DEFAULT_SPECULAR_SETTINGS.hairIntensity, [], { min: 0, max: 8 })
242
+ : 0,
243
+ hairPower: enabled
244
+ ? roleNumber(source, 'hair', 'power', DEFAULT_SPECULAR_SETTINGS.hairPower, [], { min: 1, max: 512 })
245
+ : DEFAULT_SPECULAR_SETTINGS.hairPower,
246
+ maskChannel: normalizeMaskChannel(firstDefined(source, ['maskChannel', 'specularMaskChannel'])),
247
+ maskMap: firstDefined(source, ['maskMap', 'specularMaskMap']) ?? DEFAULT_SPECULAR_SETTINGS.maskMap,
248
+ maskStrength: enabled
249
+ ? numberOption(
250
+ firstDefined(source, ['maskStrength', 'specularMaskStrength']),
251
+ DEFAULT_SPECULAR_SETTINGS.maskStrength,
252
+ { min: 0, max: 1 },
253
+ )
254
+ : 0,
255
+ metalIntensity: enabled
256
+ ? roleNumber(source, 'metal', 'intensity', DEFAULT_SPECULAR_SETTINGS.metalIntensity, [], { min: 0, max: 8 })
257
+ : 0,
258
+ skinIntensity: enabled
259
+ ? roleNumber(source, 'skin', 'intensity', DEFAULT_SPECULAR_SETTINGS.skinIntensity, [], { min: 0, max: 8 })
260
+ : 0,
261
+ sourceMaskMode: normalizeSourceMaskMode(
262
+ firstDefined(source, ['sourceMaskMode', 'sourceMask', 'useSourceMask', 'useSourceSpecularMap']),
263
+ ),
264
+ };
265
+ }
266
+
267
+ export function resolveSpecularForMaterial(settings, {
268
+ isEye = false,
269
+ isFace = false,
270
+ isHair = false,
271
+ isMetal = false,
272
+ isOutline = false,
273
+ isSkin = false,
274
+ maskMap = null,
275
+ } = {}) {
276
+ let intensity = settings.defaultIntensity;
277
+ let midPoint = settings.defaultMidPoint;
278
+ let power = settings.defaultPower;
279
+ let range = settings.defaultRange;
280
+ let showInShadowArea = settings.defaultShowInShadowArea;
281
+
282
+ if (isEye) {
283
+ intensity = settings.eyeIntensity;
284
+ midPoint = settings.eyeMidPoint;
285
+ power = settings.eyePower;
286
+ range = settings.eyeRange;
287
+ showInShadowArea = settings.eyeShowInShadowArea;
288
+ } else if (isFace) {
289
+ intensity = settings.faceIntensity;
290
+ } else if (isSkin) {
291
+ intensity = settings.skinIntensity;
292
+ } else if (isHair) {
293
+ intensity = settings.hairIntensity;
294
+ power = settings.hairPower;
295
+ } else if (isMetal) {
296
+ intensity = settings.metalIntensity;
297
+ }
298
+
299
+ const resolvedMaskMap = settings.maskMap ?? maskMap;
300
+ return {
301
+ color: settings.defaultColor,
302
+ directionModeValue: SPECULAR_DIRECTION_MODE_VALUES[settings.directionMode] ?? 0,
303
+ enabled: settings.enabled && !isOutline && intensity > 0,
304
+ intensity,
305
+ maskChannel: settings.maskChannel,
306
+ maskMap: resolvedMaskMap,
307
+ maskStrength: settings.maskStrength,
308
+ midPoint,
309
+ power,
310
+ range,
311
+ showInShadowArea,
312
+ useMask: Boolean(resolvedMaskMap) && settings.maskStrength > 0,
313
+ };
314
+ }
@@ -0,0 +1,102 @@
1
+ // Sticker / decal overlay.
2
+ //
3
+ // Blends an overlay texture into the albedo before lighting, so the sticker
4
+ // shades with the same cel bands as the rest of the surface. Covers the
5
+ // "runtime texture overlay" use case (ice, rock, tattoos, damage decals).
6
+ // The sampler is compiled in per material only when a map is present.
7
+ //
8
+ // Map sources, in priority order:
9
+ // 1. settings.map (applies to every matching material)
10
+ // 2. material.userData.toonStickerMap / stickerMap (per-material)
11
+
12
+ export const STICKER_BLEND_MODES = Object.freeze({
13
+ normal: 'normal',
14
+ add: 'add',
15
+ multiply: 'multiply',
16
+ });
17
+
18
+ export const STICKER_BLEND_MODE_VALUES = Object.freeze({
19
+ [STICKER_BLEND_MODES.normal]: 0,
20
+ [STICKER_BLEND_MODES.add]: 1,
21
+ [STICKER_BLEND_MODES.multiply]: 2,
22
+ });
23
+
24
+ export const DEFAULT_STICKER_SETTINGS = Object.freeze({
25
+ blendMode: STICKER_BLEND_MODES.normal,
26
+ enabled: false,
27
+ map: null,
28
+ offset: [0, 0],
29
+ repeat: [1, 1],
30
+ strength: 1,
31
+ // 0 = uv, 1 = uv2.
32
+ uvChannel: 0,
33
+ });
34
+
35
+ function firstDefined(source, keys) {
36
+ for (const key of keys) {
37
+ if (source?.[key] !== undefined) return source[key];
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function numberOption(value, fallback, { min = -Infinity, max = Infinity } = {}) {
43
+ const nextValue = Number(value);
44
+ if (!Number.isFinite(nextValue)) return fallback;
45
+ return Math.min(max, Math.max(min, nextValue));
46
+ }
47
+
48
+ function vector2Option(value, fallback) {
49
+ if (Array.isArray(value) && value.length >= 2) {
50
+ return [numberOption(value[0], fallback[0]), numberOption(value[1], fallback[1])];
51
+ }
52
+ if (typeof value === 'object' && value !== null) {
53
+ return [numberOption(value.x, fallback[0]), numberOption(value.y, fallback[1])];
54
+ }
55
+ return [...fallback];
56
+ }
57
+
58
+ function blendModeOption(value, fallback) {
59
+ const key = String(value ?? '').trim().toLowerCase();
60
+ if (['add', 'additive', 'screen'].includes(key)) return STICKER_BLEND_MODES.add;
61
+ if (['multiply', 'mul', 'darken'].includes(key)) return STICKER_BLEND_MODES.multiply;
62
+ if (['normal', 'blend', 'alpha', 'alphablend'].includes(key)) return STICKER_BLEND_MODES.normal;
63
+ return fallback;
64
+ }
65
+
66
+ function normalizeStickerOptions(options) {
67
+ if (options === false) return { enabled: false };
68
+ if (options === true) return { enabled: true };
69
+ return options || {};
70
+ }
71
+
72
+ export function createStickerSettings(options = null) {
73
+ const source = normalizeStickerOptions(options);
74
+ const enabled = source.enabled === true || (source.enabled !== false && Boolean(source.map));
75
+
76
+ return {
77
+ blendMode: blendModeOption(firstDefined(source, ['blendMode', 'blend', 'mode']), DEFAULT_STICKER_SETTINGS.blendMode),
78
+ enabled,
79
+ map: firstDefined(source, ['map', 'stickerMap', 'texture']) ?? DEFAULT_STICKER_SETTINGS.map,
80
+ offset: vector2Option(firstDefined(source, ['offset', 'uvOffset']), DEFAULT_STICKER_SETTINGS.offset),
81
+ repeat: vector2Option(firstDefined(source, ['repeat', 'uvRepeat', 'tiling']), DEFAULT_STICKER_SETTINGS.repeat),
82
+ strength: numberOption(firstDefined(source, ['strength', 'intensity', 'opacity']), DEFAULT_STICKER_SETTINGS.strength, { min: 0, max: 1 }),
83
+ uvChannel: numberOption(firstDefined(source, ['uvChannel']), DEFAULT_STICKER_SETTINGS.uvChannel, { min: 0, max: 1 }) >= 0.5 ? 1 : 0,
84
+ };
85
+ }
86
+
87
+ export function resolveStickerForMaterial(settings, { isOutline = false, sourceMaterial = null } = {}) {
88
+ const materialMap = sourceMaterial?.userData?.toonStickerMap ??
89
+ sourceMaterial?.userData?.stickerMap ??
90
+ null;
91
+ const map = settings.map ?? materialMap;
92
+
93
+ return {
94
+ blendModeValue: STICKER_BLEND_MODE_VALUES[settings.blendMode] ?? 0,
95
+ enabled: settings.enabled && !isOutline && Boolean(map) && settings.strength > 0,
96
+ map,
97
+ offset: settings.offset,
98
+ repeat: settings.repeat,
99
+ strength: settings.strength,
100
+ uvChannel: settings.uvChannel,
101
+ };
102
+ }