@call-me-sensei/toonlab 0.1.1 → 0.2.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.
@@ -33,7 +33,9 @@ import {
33
33
  cameraProjectionMatrix,
34
34
  cameraViewMatrix,
35
35
  clamp,
36
+ Discard,
36
37
  dot,
38
+ exp,
37
39
  float,
38
40
  Fn,
39
41
  If,
@@ -139,6 +141,21 @@ export function createWaterNodeMaterial({
139
141
  // `.value` like ShaderMaterial uniform entries; array uniforms are
140
142
  // wrapped — see waterArrayUniformEntry) ----
141
143
  const u = {
144
+ // Manual scene-fog (linear, matches THREE.Fog). material.fog stays false
145
+ // (see the fog-ordering TODO above); WaterSurface.update() mirrors
146
+ // scene.fog into these each frame so distant water fades into the same
147
+ // haze as everything else instead of cutting sharp against fogged
148
+ // terrain. far <= near disables.
149
+ uSceneFogColor: uniform(new THREE.Color(0.72, 0.83, 0.94)),
150
+ uSceneFogFar: uniform(0),
151
+ uSceneFogNear: uniform(0),
152
+ // Exponential distance fog matching the environment shader's height fog
153
+ // (water sits at the bottom of the height falloff, so the depth term is
154
+ // the whole story). Without this, water at the far shore stays bright
155
+ // while terrain behind it hazes out — reading like water slicing into
156
+ // the mountains. density 0 disables.
157
+ uDistanceFogColor: uniform(new THREE.Color(0.66, 0.8, 0.94)),
158
+ uDistanceFogDensity: uniform(0),
142
159
  uTime: uniform(0),
143
160
  uResolution: uniform(new THREE.Vector2(1, 1)),
144
161
  uCameraNear: uniform(0.05),
@@ -244,6 +261,10 @@ export function createWaterNodeMaterial({
244
261
  const vBreaker = varying(float(), 'vWaterBreaker');
245
262
  const vSwash = varying(float(), 'vWaterSwash');
246
263
  const vChop = varying(float(), 'vWaterChop');
264
+ // Rest-pose column depth (waterline − bed). Deeply negative = the surface
265
+ // is under dry land; the fragment shader discards those fragments so
266
+ // triangles spanning a cliff face can't poke through the terrain.
267
+ const vRestDepth = varying(float(), 'vWaterRestDepth');
247
268
 
248
269
  material.vertexNode = Fn(() => {
249
270
  const worldPosition = modelWorldMatrix.mul(vec4(positionGeometry, 1.0)).toVar();
@@ -263,6 +284,7 @@ export function createWaterNodeMaterial({
263
284
  if (flags.shoaling) {
264
285
  const aBedHeight = attribute('aBedHeight', 'float');
265
286
  const restDepth = worldPosition.y.sub(aBedHeight).toVar();
287
+ vRestDepth.assign(restDepth);
266
288
  // Big swells feel the bottom much deeper than small ones.
267
289
  const shoalRange = max(max(u.uShoalingDepth, u.uWaveEnergy.mul(2.2)), 1e-3).toVar();
268
290
  chopWeight.assign(mix(0.15, 1.0, smoothstep(shoalRange.mul(0.3), shoalRange.mul(1.4), restDepth)));
@@ -289,7 +311,14 @@ export function createWaterNodeMaterial({
289
311
  const runup = u.uShorelineRunup.mul(u.uWaveEnergy).mul(max(wavePhase, 0.0));
290
312
  // Dry land tucks the film 20cm under the terrain.
291
313
  const film = clamp(restDepth.add(runup), -0.2, 0.05).toVar();
292
- const filmY = aBedHeight.sub(worldPosition.y).add(film);
314
+ // Cap the drape: the film follows the bed up the beach, but a steep
315
+ // bank (terraced shore, cliff base) puts a whole cliff step inside one
316
+ // mesh cell — the dry vertex would ride 10m+ up the slope and drag
317
+ // surviving near-shore fragments with it (giant white "iceberg"
318
+ // wedges at distant shorelines). Real swash never climbs above ~½m
319
+ // over the rest waterline, so clamp the geometry there and let the
320
+ // vRestDepth fragment discard own the rest.
321
+ const filmY = min(aBedHeight.sub(worldPosition.y).add(film), 0.5);
293
322
  const beach = smoothstep(-0.02, 0.06, restDepth).oneMinus().toVar();
294
323
 
295
324
  rippleShoal.assign(smoothstep(0.0, 0.12, restDepth));
@@ -301,6 +330,7 @@ export function createWaterNodeMaterial({
301
330
  rawWave.z.mul(shoal),
302
331
  ));
303
332
  } else {
333
+ vRestDepth.assign(float(1000.0));
304
334
  rawWave = waves.gerstnerDisplacementFiltered(restXZ, u.uTime, chopWeight).toVar();
305
335
  waveDisplacement.assign(rawWave);
306
336
  }
@@ -324,6 +354,12 @@ export function createWaterNodeMaterial({
324
354
 
325
355
  // ---- Fragment stage (water.frag.glsl) ----
326
356
  material.fragmentNode = Fn(() => {
357
+ // Surface sections tucked under dry land (see vRestDepth) never shade:
358
+ // without this, water triangles spanning steep banks slice through the
359
+ // terrain as visible shards.
360
+ if (flags.shoaling) {
361
+ Discard(vRestDepth.lessThan(-0.35));
362
+ }
327
363
  const screenUv = screenUV.toVar();
328
364
  const toCamera = cameraPosition.sub(vWorldPosition).toVar();
329
365
  const viewDir = normalize(toCamera).toVar();
@@ -460,6 +496,23 @@ export function createWaterNodeMaterial({
460
496
  alpha.assign(max(alpha, vSwash.mul(0.55)));
461
497
  alpha.assign(max(alpha, clamp(reflectionMix.add(fresnel.mul(0.4)), 0.0, 1.0).mul(0.85)));
462
498
 
499
+ // Fog last: foam, sparkles, and shorelines haze out with the rest of
500
+ // the world. Exponential term mirrors the environment height fog;
501
+ // linear term mirrors scene.fog.
502
+ If(u.uDistanceFogDensity.greaterThan(0.0), () => {
503
+ const depthTerm = exp(viewDistance.mul(u.uDistanceFogDensity).negate()).oneMinus();
504
+ litColor.assign(mix(litColor, u.uDistanceFogColor, clamp(depthTerm, 0.0, 1.0)));
505
+ });
506
+ If(u.uSceneFogFar.greaterThan(u.uSceneFogNear), () => {
507
+ const fogAmount = clamp(
508
+ viewDistance.sub(u.uSceneFogNear)
509
+ .div(max(u.uSceneFogFar.sub(u.uSceneFogNear), 1e-3)),
510
+ 0.0,
511
+ 1.0,
512
+ );
513
+ litColor.assign(mix(litColor, u.uSceneFogColor, fogAmount));
514
+ });
515
+
463
516
  // --- debug visualizations (?waterDebug=...) ---
464
517
  // Masked sum instead of an if/else chain (docs/tsl-conventions.md #3);
465
518
  // always compiled — every input already lives in the graph.
@@ -35,6 +35,53 @@ export const DEFAULT_SKY_SETTINGS = Object.freeze({
35
35
  starsStrength: 0.0,
36
36
  });
37
37
 
38
+ // Named sky presets: 'default' is the baseline; 'call_me_sensei' is the
39
+ // studio-managed signature look, curated and updated over releases.
40
+ // Community presets register alongside them via registerSkyPreset().
41
+ const skyPresetRegistry = new Map([
42
+ ['default', Object.freeze({
43
+ description: 'Baseline daytime sky.',
44
+ label: 'Default',
45
+ settings: Object.freeze({}),
46
+ })],
47
+ ['call_me_sensei', Object.freeze({
48
+ description: 'Studio-managed signature sky, curated by Call Me Sensei and updated over releases. Currently the tuned library defaults.',
49
+ label: 'Call Me Sensei',
50
+ settings: Object.freeze({}),
51
+ })],
52
+ ]);
53
+
54
+ /**
55
+ * Registers a named sky preset so it resolves in `createSkySettings({
56
+ * preset })` exactly like the built-ins. Accepts `{ label?, description?,
57
+ * settings? }` or flat settings.
58
+ */
59
+ export function registerSkyPreset(name, preset = {}, { overwrite = false } = {}) {
60
+ const id = String(name ?? '').trim();
61
+ if (!id) throw new Error('Sky preset name is required.');
62
+ if (!overwrite && skyPresetRegistry.has(id)) {
63
+ throw new Error(`Sky preset "${id}" already exists.`);
64
+ }
65
+ const source = preset && typeof preset === 'object' ? preset : {};
66
+ const { label, description, settings, ...flat } = source;
67
+ const entry = Object.freeze({
68
+ description: typeof description === 'string' ? description : '',
69
+ label: typeof label === 'string' && label ? label : id,
70
+ settings: Object.freeze({ ...(settings && typeof settings === 'object' ? settings : flat) }),
71
+ });
72
+ skyPresetRegistry.set(id, entry);
73
+ return { description: entry.description, id, label: entry.label };
74
+ }
75
+
76
+ /** Lists registered sky presets as `{ id, label, description }` (for HUDs). */
77
+ export function getSkyPresetOptions() {
78
+ return Array.from(skyPresetRegistry.entries()).map(([id, preset]) => ({
79
+ description: preset.description,
80
+ id,
81
+ label: preset.label,
82
+ }));
83
+ }
84
+
38
85
  function finiteNumber(value, fallback) {
39
86
  const number = Number(value);
40
87
  return Number.isFinite(number) ? number : fallback;
@@ -77,7 +124,8 @@ function vector3Array(value, fallback) {
77
124
  */
78
125
  export function createSkySettings(options = {}) {
79
126
  const source = options && typeof options === 'object' ? options : {};
80
- const base = DEFAULT_SKY_SETTINGS;
127
+ const presetSettings = skyPresetRegistry.get(source.preset)?.settings;
128
+ const base = presetSettings ? { ...DEFAULT_SKY_SETTINGS, ...presetSettings } : DEFAULT_SKY_SETTINGS;
81
129
  return {
82
130
  radius: Math.max(finiteNumber(source.radius, base.radius), 0.1),
83
131
  zenithColor: colorArray(source.zenithColor, base.zenithColor),