@call-me-sensei/toonlab 0.2.0 → 0.3.0

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 (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,675 @@
1
+ // Turns a texture settings document into a full set of seamlessly tiling
2
+ // PBR maps: albedo, height, normal, roughness, metalness, AO, packed ORM,
3
+ // and optional emissive. Pure CPU + typed arrays — headless-safe (no DOM,
4
+ // no Math.random), deterministic per seed, chunked so the UI thread
5
+ // breathes during big bakes.
6
+ //
7
+ // Image-base mode: pass decoded RGBA pixels via options.imagePixels and the
8
+ // image replaces the BASE layer (albedo + height from band-split luminance,
9
+ // seamless-ized by a torus blend) while detail layers, overlays, wear,
10
+ // color grading, glow, and all PBR derivation keep working on top. Decoding
11
+ // stays the caller's job so this module remains DOM-free.
12
+
13
+ import { hashCombine } from '../rockgen/noise/prng.js';
14
+ import { periodicFbm2 } from './noise2.js';
15
+ import { compileTextureLayer } from './textureGenerators.js';
16
+ import { createTextureSettings } from './textureSettings.js';
17
+
18
+ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
19
+
20
+ function smoothstep(edge0, edge1, x) {
21
+ if (edge0 === edge1) return x < edge0 ? 0 : 1;
22
+ const t = clamp01((x - edge0) / (edge1 - edge0));
23
+ return t * t * (3 - 2 * t);
24
+ }
25
+
26
+ // --- color helpers (exact sRGB transfer, linear-space ramp blending) ------
27
+
28
+ export function srgbToLinear(c) {
29
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
30
+ }
31
+
32
+ export function linearToSrgb(c) {
33
+ const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
34
+ return v < 0 ? 0 : v > 1 ? 1 : v;
35
+ }
36
+
37
+ function toLinear(rgb) {
38
+ return [srgbToLinear(rgb[0]), srgbToLinear(rgb[1]), srgbToLinear(rgb[2])];
39
+ }
40
+
41
+ /** Hue rotation around the gray axis in linear RGB (angle in turns). */
42
+ function hueRotate(r, g, b, turns) {
43
+ const angle = turns * Math.PI * 2;
44
+ const cosA = Math.cos(angle);
45
+ const sinA = Math.sin(angle);
46
+ const m00 = 0.213 + cosA * 0.787 - sinA * 0.213;
47
+ const m01 = 0.715 - cosA * 0.715 - sinA * 0.715;
48
+ const m02 = 0.072 - cosA * 0.072 + sinA * 0.928;
49
+ const m10 = 0.213 - cosA * 0.213 + sinA * 0.143;
50
+ const m11 = 0.715 + cosA * 0.285 + sinA * 0.14;
51
+ const m12 = 0.072 - cosA * 0.072 - sinA * 0.283;
52
+ const m20 = 0.213 - cosA * 0.213 - sinA * 0.787;
53
+ const m21 = 0.715 - cosA * 0.715 + sinA * 0.715;
54
+ const m22 = 0.072 + cosA * 0.928 + sinA * 0.072;
55
+ return [
56
+ m00 * r + m01 * g + m02 * b,
57
+ m10 * r + m11 * g + m12 * b,
58
+ m20 * r + m21 * g + m22 * b,
59
+ ];
60
+ }
61
+
62
+ /** Compiles the five-stop ramp into `t -> [r, g, b]` (linear output). */
63
+ function compileRamp(color) {
64
+ const stops = [
65
+ { pos: 0, rgb: toLinear(color.color0) },
66
+ { pos: color.pos1, rgb: toLinear(color.color1) },
67
+ { pos: color.pos2, rgb: toLinear(color.color2) },
68
+ { pos: color.pos3, rgb: toLinear(color.color3) },
69
+ { pos: 1, rgb: toLinear(color.color4) },
70
+ ].sort((a, b) => a.pos - b.pos);
71
+ const smoothness = Math.max(0.0001, color.rampSmooth);
72
+ return (t, out) => {
73
+ let hi = 1;
74
+ while (hi < stops.length - 1 && stops[hi].pos < t) hi += 1;
75
+ const a = stops[hi - 1];
76
+ const b = stops[hi];
77
+ const span = Math.max(1e-6, b.pos - a.pos);
78
+ let f = clamp01((t - a.pos) / span);
79
+ // rampSmooth 1 = linear blend, 0 = hard cel band at the segment middle.
80
+ f = clamp01((f - 0.5) / smoothness + 0.5);
81
+ out[0] = a.rgb[0] + (b.rgb[0] - a.rgb[0]) * f;
82
+ out[1] = a.rgb[1] + (b.rgb[1] - a.rgb[1]) * f;
83
+ out[2] = a.rgb[2] + (b.rgb[2] - a.rgb[2]) * f;
84
+ };
85
+ }
86
+
87
+ // --- layer blending --------------------------------------------------------
88
+
89
+ function overlayOp(a, b) {
90
+ return a < 0.5 ? 2 * a * b : 1 - 2 * (1 - a) * (1 - b);
91
+ }
92
+
93
+ function blendHeight(mode, h, d) {
94
+ switch (mode) {
95
+ case 'add': return clamp01(h + (d - 0.5));
96
+ case 'multiply': return h * d;
97
+ case 'screen': return 1 - (1 - h) * (1 - d);
98
+ case 'min': return Math.min(h, d);
99
+ case 'max': return Math.max(h, d);
100
+ case 'mix': return d;
101
+ case 'overlay':
102
+ default: return overlayOp(h, d);
103
+ }
104
+ }
105
+
106
+ // --- accent + emissive masks -----------------------------------------------
107
+
108
+ function compileAccent(spec, seed, salt) {
109
+ if (!spec.enabled) return null;
110
+ const sample = compileTextureLayer(spec, seed, salt);
111
+ const threshold = 1 - clamp01(spec.coverage);
112
+ const softness = Math.max(0.01, spec.softness);
113
+ const bias = Math.min(1, Math.max(-1, spec.creviceBias));
114
+ const colorA = toLinear(spec.color);
115
+ const colorB = toLinear(spec.colorB);
116
+ return {
117
+ blend: spec.blend,
118
+ colorA,
119
+ colorB,
120
+ heightShift: spec.heightShift,
121
+ metalShift: spec.metalShift,
122
+ roughnessShift: spec.roughnessShift,
123
+ mask(u, v, h) {
124
+ const out = sample(u, v);
125
+ let m = smoothstep(threshold - softness, threshold + softness, out.v);
126
+ if (bias > 0) m *= 1 - bias + bias * smoothstep(0.85, 0.15, h);
127
+ else if (bias < 0) m *= 1 + bias - bias * smoothstep(0.15, 0.85, h);
128
+ return { m: clamp01(m), t: out.v };
129
+ },
130
+ };
131
+ }
132
+
133
+ function emissiveMask(source, h, mA, mB, threshold, width, softness) {
134
+ switch (source) {
135
+ case 'crevices': return 1 - smoothstep(threshold, threshold + Math.max(0.02, width), h);
136
+ case 'peaks': return smoothstep(threshold - Math.max(0.02, width), threshold, h);
137
+ case 'band': {
138
+ const half = width * 0.5;
139
+ return smoothstep(threshold - half - softness, threshold - half, h)
140
+ * (1 - smoothstep(threshold + half, threshold + half + softness, h));
141
+ }
142
+ case 'accentA': return mA;
143
+ case 'accentB': return mB;
144
+ case 'everywhere':
145
+ default: return 1;
146
+ }
147
+ }
148
+
149
+ // --- post passes ------------------------------------------------------------
150
+
151
+ /** Separable box blur with toroidal wrap on a Float32 field. */
152
+ function wrapBoxBlur(src, size, radius, tmp, dst) {
153
+ const r = Math.max(1, Math.round(radius));
154
+ const norm = 1 / (r * 2 + 1);
155
+ // horizontal
156
+ for (let y = 0; y < size; y += 1) {
157
+ const row = y * size;
158
+ let acc = 0;
159
+ for (let k = -r; k <= r; k += 1) acc += src[row + ((k + size) % size)];
160
+ for (let x = 0; x < size; x += 1) {
161
+ tmp[row + x] = acc * norm;
162
+ const add = src[row + ((x + r + 1) % size)];
163
+ const sub = src[row + ((x - r + size) % size)];
164
+ acc += add - sub;
165
+ }
166
+ }
167
+ // vertical
168
+ for (let x = 0; x < size; x += 1) {
169
+ let acc = 0;
170
+ for (let k = -r; k <= r; k += 1) acc += tmp[((k + size) % size) * size + x];
171
+ for (let y = 0; y < size; y += 1) {
172
+ dst[y * size + x] = acc * norm;
173
+ const add = tmp[((y + r + 1) % size) * size + x];
174
+ const sub = tmp[((y - r + size) % size) * size + x];
175
+ acc += add - sub;
176
+ }
177
+ }
178
+ }
179
+
180
+ // --- image base ---------------------------------------------------------------
181
+
182
+ export const DEFAULT_TEXTURE_IMAGE_PARAMS = Object.freeze({
183
+ bands: 0,
184
+ heightBase: 0.35,
185
+ heightDetail: 0.65,
186
+ seamless: true,
187
+ });
188
+
189
+ /** Triangle weight peaking at the tile center — the torus-blend kernel. */
190
+ function triWeight(t) {
191
+ const w = 1 - Math.abs(2 * (t - Math.floor(t)) - 1);
192
+ return 0.0001 + w * w;
193
+ }
194
+
195
+ /** Bilinear sample (wrapping) of source RGBA bytes -> linear RGB into out. */
196
+ function sampleBilinear(pixels, sx, sy, out) {
197
+ const { data, width, height } = pixels;
198
+ const x0 = Math.floor(sx);
199
+ const y0 = Math.floor(sy);
200
+ const fx = sx - x0;
201
+ const fy = sy - y0;
202
+ const xa = ((x0 % width) + width) % width;
203
+ const xb = (xa + 1) % width;
204
+ const ya = ((y0 % height) + height) % height;
205
+ const yb = (ya + 1) % height;
206
+ for (let c = 0; c < 3; c += 1) {
207
+ const p00 = data[(ya * width + xa) * 4 + c] / 255;
208
+ const p10 = data[(ya * width + xb) * 4 + c] / 255;
209
+ const p01 = data[(yb * width + xa) * 4 + c] / 255;
210
+ const p11 = data[(yb * width + xb) * 4 + c] / 255;
211
+ out[c] = srgbToLinear(p00 + (p10 - p00) * fx + ((p01 + (p11 - p01) * fx) - (p00 + (p10 - p00) * fx)) * fy);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Resamples decoded image pixels into the working tile: seamless-ized
217
+ * linear albedo plus a band-split luminance height field (local detail
218
+ * emphasized, baked-in photo lighting flattened).
219
+ */
220
+ function prepareImageField(imagePixels, size, params) {
221
+ const { seamless, heightDetail, heightBase } = { ...DEFAULT_TEXTURE_IMAGE_PARAMS, ...params };
222
+ const albedo = new Float32Array(size * size * 3);
223
+ const luma = new Float32Array(size * size);
224
+ const sample = [0, 0, 0];
225
+ const accum = [0, 0, 0];
226
+ const taps = seamless
227
+ ? [[0, 0], [0.5, 0], [0, 0.5], [0.5, 0.5]]
228
+ : [[0, 0]];
229
+ for (let y = 0; y < size; y += 1) {
230
+ const v = (y + 0.5) / size;
231
+ for (let x = 0; x < size; x += 1) {
232
+ const u = (x + 0.5) / size;
233
+ accum[0] = 0;
234
+ accum[1] = 0;
235
+ accum[2] = 0;
236
+ let weightSum = 0;
237
+ for (const [ou, ov] of taps) {
238
+ // Weight each shifted copy by distance to ITS tile center so every
239
+ // edge is covered by a copy whose seam lies elsewhere.
240
+ const wu = u + ou;
241
+ const wv = v + ov;
242
+ const weight = taps.length === 1 ? 1 : triWeight(wu) * triWeight(wv);
243
+ sampleBilinear(imagePixels, (wu - Math.floor(wu)) * imagePixels.width - 0.5, (wv - Math.floor(wv)) * imagePixels.height - 0.5, sample);
244
+ accum[0] += sample[0] * weight;
245
+ accum[1] += sample[1] * weight;
246
+ accum[2] += sample[2] * weight;
247
+ weightSum += weight;
248
+ }
249
+ const i = y * size + x;
250
+ albedo[i * 3] = accum[0] / weightSum;
251
+ albedo[i * 3 + 1] = accum[1] / weightSum;
252
+ albedo[i * 3 + 2] = accum[2] / weightSum;
253
+ luma[i] = 0.2126 * albedo[i * 3] + 0.7152 * albedo[i * 3 + 1] + 0.0722 * albedo[i * 3 + 2];
254
+ }
255
+ }
256
+
257
+ // Contrast-stretch the luminance, then split into a low-frequency base
258
+ // (photo lighting, damped) and high-frequency detail (surface relief).
259
+ let lo = 1;
260
+ let hi = 0;
261
+ for (let i = 0; i < luma.length; i += 1) {
262
+ if (luma[i] < lo) lo = luma[i];
263
+ if (luma[i] > hi) hi = luma[i];
264
+ }
265
+ const span = Math.max(1e-4, hi - lo);
266
+ for (let i = 0; i < luma.length; i += 1) luma[i] = (luma[i] - lo) / span;
267
+ const tmp = new Float32Array(size * size);
268
+ const blurred = new Float32Array(size * size);
269
+ wrapBoxBlur(luma, size, Math.max(2, size / 12), tmp, blurred);
270
+ const height = new Float32Array(size * size);
271
+ for (let i = 0; i < luma.length; i += 1) {
272
+ height[i] = clamp01(0.5 + (luma[i] - blurred[i]) * heightDetail * 2.4 + (blurred[i] - 0.5) * heightBase);
273
+ }
274
+ return { albedo, height };
275
+ }
276
+
277
+ // --- result buffers ----------------------------------------------------------
278
+
279
+ export const TEXTURE_MAP_IDS = Object.freeze(['albedo', 'height', 'normal', 'roughness', 'metalness', 'ao', 'orm', 'emissive']);
280
+
281
+ function allocResult(size, target) {
282
+ if (target && target.size === size && target.albedo?.length === size * size * 4) return target;
283
+ const bytes = () => new Uint8Array(size * size * 4);
284
+ return {
285
+ albedo: bytes(),
286
+ ao: bytes(),
287
+ emissive: bytes(),
288
+ height: new Float32Array(size * size),
289
+ heightBytes: bytes(),
290
+ metalness: bytes(),
291
+ normal: bytes(),
292
+ orm: bytes(),
293
+ roughness: bytes(),
294
+ size,
295
+ };
296
+ }
297
+
298
+ const yieldToUi = () => new Promise((resolve) => setTimeout(resolve, 0));
299
+
300
+ /**
301
+ * Bakes every map for `settings` at `size`x`size`. Async and cancellable:
302
+ * returns null when `shouldCancel()` goes true mid-bake. Pass the previous
303
+ * result as `target` to reuse its buffers (same size only).
304
+ *
305
+ * Returns { size, albedo, heightBytes, normal, roughness, metalness, ao,
306
+ * orm, emissive, height (Float32), emissiveEnabled, ms }.
307
+ */
308
+ export async function evaluateTextureMaps(rawSettings, {
309
+ size = 256,
310
+ target = null,
311
+ onProgress = null,
312
+ shouldCancel = null,
313
+ imagePixels = null,
314
+ imageParams = null,
315
+ } = {}) {
316
+ const started = (typeof performance !== 'undefined' ? performance.now() : 0);
317
+ const settings = createTextureSettings(rawSettings);
318
+ const seed = settings.global.seed >>> 0;
319
+ const result = allocResult(size, target);
320
+
321
+ // Image base: the settings' image layer carries per-document params; a
322
+ // bare imagePixels + imageParams works for library callers without one.
323
+ const imageField = imagePixels
324
+ ? prepareImageField(imagePixels, size, settings.image ?? imageParams ?? {})
325
+ : null;
326
+ const imageBands = imageField ? Math.round((settings.image ?? imageParams ?? {}).bands ?? 0) : 0;
327
+
328
+ const base = compileTextureLayer(settings.base, seed, 0x0b45e);
329
+ const details = [settings.detailA, settings.detailB]
330
+ .map((layer, index) => (layer.enabled && layer.amount > 0
331
+ ? { amount: layer.amount, blend: layer.blend, sample: compileTextureLayer(layer, seed, 0xde7a11 + index * 977) }
332
+ : null))
333
+ .filter(Boolean);
334
+ const accentA = compileAccent(settings.accentA, seed, 0xacce17);
335
+ const accentB = compileAccent(settings.accentB, seed, 0xacce55);
336
+ const ramp = compileRamp(settings.color);
337
+
338
+ // Wear macros: one knob compiles to fixed seeded sub-layers so "damage"
339
+ // and "dirt" never rewrite the user's other sliders (debris-lab pattern).
340
+ const damage = settings.wear.damage;
341
+ const dirt = settings.wear.dirt;
342
+ const scratches = damage > 0 ? compileTextureLayer({
343
+ cellJitter: 1, detail: 3, detailGain: 0.5, edgeWidth: 0.045, generator: 'cracks',
344
+ scale: 9, stretchX: 2.6, stretchY: 1, warp: 0.3, warpScale: 4,
345
+ }, seed, 0x5c9a7c) : null;
346
+ const chips = damage > 0 ? compileTextureLayer({
347
+ cellVariation: 0.45, edgeWidth: 0.16, generator: 'speckle', scale: 26,
348
+ }, seed, 0xc41b22) : null;
349
+ const grime = dirt > 0 ? compileTextureLayer({
350
+ detail: 4, detailGain: 0.55, generator: 'turbulence', scale: 5, warp: 0.3, warpScale: 3,
351
+ }, seed, 0xd127a1) : null;
352
+ const DIRT_TINT = [0.4, 0.34, 0.27];
353
+
354
+ const color = settings.color;
355
+ const surface = settings.surface;
356
+ const emissive = settings.emissive;
357
+ const jitterSeed = hashCombine(seed, 0x11a77);
358
+ const hueSeed = hashCombine(seed, 0x22b33);
359
+ const jitterScale = Math.max(2, Math.round(color.jitterScale));
360
+ const cavityTint = toLinear(color.cavityTint);
361
+ const sheenTint = toLinear(color.sheenTint);
362
+ const emissiveColor = toLinear(emissive.color);
363
+ const invertHeight = Boolean(surface.invertHeight);
364
+
365
+ const heightF = result.height;
366
+ const rgb = [0, 0, 0];
367
+ const chunkRows = Math.max(4, Math.round(2048 / Math.max(64, size) * 16));
368
+
369
+ for (let y = 0; y < size; y += 1) {
370
+ const v = (y + 0.5) / size;
371
+ for (let x = 0; x < size; x += 1) {
372
+ const u = (x + 0.5) / size;
373
+ const i = y * size + x;
374
+ const o = i * 4;
375
+
376
+ // -- height stack (image base replaces the base pattern when present)
377
+ const baseOut = imageField ? null : base(u, v);
378
+ const baseCell = baseOut ? baseOut.cell : null;
379
+ let h = imageField ? imageField.height[i] : baseOut.v;
380
+ for (const layer of details) {
381
+ const d = layer.sample(u, v).v;
382
+ h += (blendHeight(layer.blend, h, d) - h) * layer.amount;
383
+ }
384
+ h = clamp01(h);
385
+
386
+ // -- accent masks (need the pre-shift height for crevice bias)
387
+ let mA = 0;
388
+ let tA = 0;
389
+ let mB = 0;
390
+ let tB = 0;
391
+ if (accentA) {
392
+ const a = accentA.mask(u, v, h);
393
+ mA = a.m;
394
+ tA = a.t;
395
+ }
396
+ if (accentB) {
397
+ const b = accentB.mask(u, v, h);
398
+ mB = b.m;
399
+ tB = b.t;
400
+ }
401
+ if (accentA) h += accentA.heightShift * mA;
402
+ if (accentB) h += accentB.heightShift * mB;
403
+ h = clamp01(h);
404
+ if (invertHeight) h = 1 - h;
405
+
406
+ // -- damage: carve scratch lines and chips into the final surface
407
+ let scuff = 0;
408
+ if (scratches) {
409
+ const lines = 1 - scratches(u, v).v; // 1 inside a scratch
410
+ const pit = chips(u, v).v;
411
+ scuff = Math.min(1, lines + pit * 0.8);
412
+ h = clamp01(h - damage * (lines * 0.4 + pit * 0.25));
413
+ }
414
+ heightF[i] = h;
415
+
416
+ // -- albedo: image base or height ramp, plus painterly jitter
417
+ let r;
418
+ let g;
419
+ let b;
420
+ if (imageField) {
421
+ const i3 = i * 3;
422
+ r = imageField.albedo[i3];
423
+ g = imageField.albedo[i3 + 1];
424
+ b = imageField.albedo[i3 + 2];
425
+ if (color.jitterValue > 0) {
426
+ const jn = periodicFbm2(jitterSeed, u * jitterScale, v * jitterScale, jitterScale, jitterScale, 2, 0.5) * 0.5 + 0.5;
427
+ const lift = 1 + (jn - 0.5) * color.jitterValue * 1.4;
428
+ r *= lift;
429
+ g *= lift;
430
+ b *= lift;
431
+ }
432
+ if (imageBands > 1) {
433
+ // Cel banding for image bases: quantize luminance, keep chroma.
434
+ const luma = Math.max(1e-4, 0.2126 * r + 0.7152 * g + 0.0722 * b);
435
+ const banded = (Math.floor(Math.min(0.999, luma) * imageBands) + 0.5) / imageBands;
436
+ const scale = 1 + ((banded / luma) - 1) * 0.85;
437
+ r *= scale;
438
+ g *= scale;
439
+ b *= scale;
440
+ }
441
+ } else {
442
+ let t = h;
443
+ if (color.jitterValue > 0) {
444
+ let jn;
445
+ if (color.jitterCells && baseCell !== null) {
446
+ jn = ((baseCell >>> 8) & 0xffff) / 65536;
447
+ } else {
448
+ jn = periodicFbm2(jitterSeed, u * jitterScale, v * jitterScale, jitterScale, jitterScale, 2, 0.5) * 0.5 + 0.5;
449
+ }
450
+ t = clamp01(t + (jn - 0.5) * color.jitterValue);
451
+ }
452
+ ramp(t, rgb);
453
+ r = rgb[0];
454
+ g = rgb[1];
455
+ b = rgb[2];
456
+ }
457
+
458
+ if (color.jitterHue > 0) {
459
+ let hn;
460
+ if (color.jitterCells && baseCell !== null) {
461
+ hn = (baseCell & 0xffff) / 65536;
462
+ } else {
463
+ hn = periodicFbm2(hueSeed, u * jitterScale, v * jitterScale, jitterScale, jitterScale, 2, 0.5) * 0.5 + 0.5;
464
+ }
465
+ [r, g, b] = hueRotate(r, g, b, (hn - 0.5) * color.jitterHue);
466
+ }
467
+
468
+ // -- cavity darkening & ridge sheen (the hand-painted read)
469
+ if (color.cavity > 0) {
470
+ const c = Math.min(1, color.cavity * Math.pow(1 - h, 1.6) * 1.5);
471
+ r += (r * cavityTint[0] - r) * c;
472
+ g += (g * cavityTint[1] - g) * c;
473
+ b += (b * cavityTint[2] - b) * c;
474
+ }
475
+ if (color.sheen > 0) {
476
+ const s = color.sheen * smoothstep(0.62, 0.96, h);
477
+ r = 1 - (1 - r) * (1 - sheenTint[0] * s);
478
+ g = 1 - (1 - g) * (1 - sheenTint[1] * s);
479
+ b = 1 - (1 - b) * (1 - sheenTint[2] * s);
480
+ }
481
+
482
+ // -- colored overlays
483
+ for (const [accent, m, tt] of [[accentA, mA, tA], [accentB, mB, tB]]) {
484
+ if (!accent || m <= 0) continue;
485
+ const ar = accent.colorA[0] + (accent.colorB[0] - accent.colorA[0]) * tt;
486
+ const ag = accent.colorA[1] + (accent.colorB[1] - accent.colorA[1]) * tt;
487
+ const ab = accent.colorA[2] + (accent.colorB[2] - accent.colorA[2]) * tt;
488
+ let nr = ar;
489
+ let ng = ag;
490
+ let nb = ab;
491
+ if (accent.blend === 'multiply') {
492
+ nr = r * ar; ng = g * ag; nb = b * ab;
493
+ } else if (accent.blend === 'screen') {
494
+ nr = 1 - (1 - r) * (1 - ar); ng = 1 - (1 - g) * (1 - ag); nb = 1 - (1 - b) * (1 - ab);
495
+ } else if (accent.blend === 'overlay') {
496
+ nr = overlayOp(r, ar); ng = overlayOp(g, ag); nb = overlayOp(b, ab);
497
+ }
498
+ r += (nr - r) * m;
499
+ g += (ng - g) * m;
500
+ b += (nb - b) * m;
501
+ }
502
+
503
+ // -- wear grading: dirt pools into crevices, scuffs lighten faintly
504
+ let dirtM = 0;
505
+ if (grime) {
506
+ const pooled = 0.35 + 0.65 * smoothstep(0.85, 0.15, h);
507
+ dirtM = Math.min(1, dirt * grime(u, v).v * pooled * 1.5);
508
+ r += (r * DIRT_TINT[0] - r) * dirtM;
509
+ g += (g * DIRT_TINT[1] - g) * dirtM;
510
+ b += (b * DIRT_TINT[2] - b) * dirtM;
511
+ }
512
+ if (scuff > 0) {
513
+ const lift = damage * scuff * 0.1;
514
+ r = 1 - (1 - r) * (1 - lift);
515
+ g = 1 - (1 - g) * (1 - lift);
516
+ b = 1 - (1 - b) * (1 - lift);
517
+ }
518
+
519
+ // -- global grade
520
+ if (color.hueShift !== 0) [r, g, b] = hueRotate(r, g, b, color.hueShift);
521
+ if (color.saturation !== 1) {
522
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
523
+ r = luma + (r - luma) * color.saturation;
524
+ g = luma + (g - luma) * color.saturation;
525
+ b = luma + (b - luma) * color.saturation;
526
+ }
527
+
528
+ let sr = linearToSrgb(Math.max(0, r)) * color.brightness;
529
+ let sg = linearToSrgb(Math.max(0, g)) * color.brightness;
530
+ let sb = linearToSrgb(Math.max(0, b)) * color.brightness;
531
+ if (color.contrast !== 0) {
532
+ const k = color.contrast >= 0 ? 1 + color.contrast * 1.6 : 1 + color.contrast * 0.85;
533
+ sr = (sr - 0.5) * k + 0.5;
534
+ sg = (sg - 0.5) * k + 0.5;
535
+ sb = (sb - 0.5) * k + 0.5;
536
+ }
537
+ if (color.gamma !== 1) {
538
+ const inv = 1 / color.gamma;
539
+ sr = Math.pow(clamp01(sr), inv);
540
+ sg = Math.pow(clamp01(sg), inv);
541
+ sb = Math.pow(clamp01(sb), inv);
542
+ }
543
+ result.albedo[o] = clamp01(sr) * 255;
544
+ result.albedo[o + 1] = clamp01(sg) * 255;
545
+ result.albedo[o + 2] = clamp01(sb) * 255;
546
+ result.albedo[o + 3] = 255;
547
+
548
+ // -- roughness / metalness
549
+ let rough = surface.roughness + surface.roughnessContrast * (0.5 - h) * 0.9;
550
+ let metal = surface.metalness;
551
+ if (accentA) { rough += accentA.roughnessShift * mA; metal += accentA.metalShift * mA; }
552
+ if (accentB) { rough += accentB.roughnessShift * mB; metal += accentB.metalShift * mB; }
553
+ rough += damage * scuff * 0.35 + dirtM * 0.3;
554
+ const roughByte = clamp01(Math.max(0.02, rough)) * 255;
555
+ const metalByte = clamp01(metal) * 255;
556
+ result.roughness[o] = roughByte;
557
+ result.roughness[o + 1] = roughByte;
558
+ result.roughness[o + 2] = roughByte;
559
+ result.roughness[o + 3] = 255;
560
+ result.metalness[o] = metalByte;
561
+ result.metalness[o + 1] = metalByte;
562
+ result.metalness[o + 2] = metalByte;
563
+ result.metalness[o + 3] = 255;
564
+
565
+ // -- height bytes (linear grayscale)
566
+ const hByte = h * 255;
567
+ result.heightBytes[o] = hByte;
568
+ result.heightBytes[o + 1] = hByte;
569
+ result.heightBytes[o + 2] = hByte;
570
+ result.heightBytes[o + 3] = 255;
571
+
572
+ // -- emissive
573
+ if (emissive.enabled) {
574
+ const e = clamp01(emissiveMask(emissive.source, h, mA, mB, emissive.threshold, emissive.width, emissive.softness));
575
+ result.emissive[o] = linearToSrgb(emissiveColor[0] * e) * 255;
576
+ result.emissive[o + 1] = linearToSrgb(emissiveColor[1] * e) * 255;
577
+ result.emissive[o + 2] = linearToSrgb(emissiveColor[2] * e) * 255;
578
+ result.emissive[o + 3] = 255;
579
+ } else if (result.emissive[o + 3] !== 255 || result.emissive[o] !== 0) {
580
+ result.emissive[o] = 0;
581
+ result.emissive[o + 1] = 0;
582
+ result.emissive[o + 2] = 0;
583
+ result.emissive[o + 3] = 255;
584
+ }
585
+ }
586
+
587
+ if (y % chunkRows === chunkRows - 1) {
588
+ if (shouldCancel?.()) return null;
589
+ onProgress?.((y + 1) / (size + 2));
590
+ await yieldToUi();
591
+ }
592
+ }
593
+
594
+ if (shouldCancel?.()) return null;
595
+
596
+ // -- normal map from height (toroidal sobel-style central differences)
597
+ const slope = surface.heightScale * 0.08 * surface.normalStrength * size * 0.5;
598
+ for (let y = 0; y < size; y += 1) {
599
+ const up = ((y - 1 + size) % size) * size;
600
+ const down = ((y + 1) % size) * size;
601
+ const row = y * size;
602
+ for (let x = 0; x < size; x += 1) {
603
+ const left = (x - 1 + size) % size;
604
+ const right = (x + 1) % size;
605
+ const gx = (heightF[row + right] - heightF[row + left]) * slope;
606
+ const gy = (heightF[down + x] - heightF[up + x]) * slope;
607
+ const inv = 1 / Math.sqrt(gx * gx + gy * gy + 1);
608
+ const o = (row + x) * 4;
609
+ result.normal[o] = (-gx * inv * 0.5 + 0.5) * 255;
610
+ result.normal[o + 1] = (gy * inv * 0.5 + 0.5) * 255; // +Y up (OpenGL convention)
611
+ result.normal[o + 2] = (inv * 0.5 + 0.5) * 255;
612
+ result.normal[o + 3] = 255;
613
+ }
614
+ }
615
+
616
+ if (shouldCancel?.()) return null;
617
+ onProgress?.((size + 1) / (size + 2));
618
+ await yieldToUi();
619
+
620
+ // -- ambient occlusion: crevices relative to two blur radii
621
+ const tmp = new Float32Array(size * size);
622
+ const blurNear = new Float32Array(size * size);
623
+ const blurFar = new Float32Array(size * size);
624
+ wrapBoxBlur(heightF, size, Math.max(1, size / 48), tmp, blurNear);
625
+ wrapBoxBlur(heightF, size, Math.max(2, size / 14), tmp, blurFar);
626
+ const aoDepth = surface.aoStrength * (0.4 + surface.heightScale);
627
+ for (let i = 0; i < size * size; i += 1) {
628
+ const occ = Math.max(0, blurNear[i] - heightF[i]) * 1.35 + Math.max(0, blurFar[i] - heightF[i]) * 0.85;
629
+ const ao = clamp01(1 - occ * 2.6 * aoDepth);
630
+ const o = i * 4;
631
+ const aoByte = ao * 255;
632
+ result.ao[o] = aoByte;
633
+ result.ao[o + 1] = aoByte;
634
+ result.ao[o + 2] = aoByte;
635
+ result.ao[o + 3] = 255;
636
+ // glTF ORM packing: R = occlusion, G = roughness, B = metalness.
637
+ result.orm[o] = aoByte;
638
+ result.orm[o + 1] = result.roughness[o];
639
+ result.orm[o + 2] = result.metalness[o];
640
+ result.orm[o + 3] = 255;
641
+ }
642
+
643
+ onProgress?.(1);
644
+ return {
645
+ ...result,
646
+ emissiveEnabled: Boolean(emissive.enabled),
647
+ emissiveIntensity: emissive.intensity,
648
+ ms: (typeof performance !== 'undefined' ? performance.now() : 0) - started,
649
+ };
650
+ }
651
+
652
+ /**
653
+ * Any bitmap -> a complete tiling toon PBR material. `imagePixels` is
654
+ * decoded RGBA ({ data, width, height }); decode stays the caller's job so
655
+ * this works headless. `params` are DEFAULT_TEXTURE_IMAGE_PARAMS overrides
656
+ * (seamless, heightDetail, heightBase, bands) and `settings` any partial
657
+ * texture settings for the grade/surface/overlay stages on top.
658
+ */
659
+ export async function imageToTextureMaps(imagePixels, {
660
+ params = {},
661
+ settings = {},
662
+ size = 512,
663
+ target = null,
664
+ onProgress = null,
665
+ shouldCancel = null,
666
+ } = {}) {
667
+ return evaluateTextureMaps(settings, {
668
+ imageParams: params,
669
+ imagePixels,
670
+ onProgress,
671
+ shouldCancel,
672
+ size,
673
+ target,
674
+ });
675
+ }