incanto 0.75.0 → 0.77.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 (39) hide show
  1. package/dist/2d.d.ts +1 -1
  2. package/dist/2d.js +4 -4
  3. package/dist/3d.d.ts +121 -3
  4. package/dist/3d.js +4 -4
  5. package/dist/{create-game-DnwhJ5nW.js → create-game-BMIPKrQW.js} +5 -5
  6. package/dist/{create-game-DVUhHs2B.js → create-game-Dai5vfVu.js} +5 -5
  7. package/dist/{editor-switch-O8mxpgRX.d.ts → editor-switch-Bzt0GzVp.d.ts} +12 -2
  8. package/dist/{environment-presets-BP77Dl0K.js → environment-presets-e_9YCnGu.js} +348 -36
  9. package/dist/{gameplay-D-JuGura.js → gameplay-Bfff_beb.js} +1468 -61
  10. package/dist/gameplay.js +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/{physics-2d-x3bCrQf0.js → physics-2d-Tuf5LAyq.js} +2 -2
  13. package/dist/{physics-3d-Bz2VPQ6D.js → physics-3d-C5MOujfQ.js} +2 -2
  14. package/dist/{picking-Buita5T9.js → picking-DIwE48sU.js} +8 -5
  15. package/dist/react.js +1 -1
  16. package/dist/{register-DxAbTO2T.js → register-7E3P1gz6.js} +1 -1
  17. package/dist/{src-DLKVc6Hj.js → src-DygJfhOb.js} +1 -1
  18. package/dist/{test-HlVUeCSn.js → test-CVbxnXlv.js} +7 -7
  19. package/dist/test.js +1 -1
  20. package/dist/vite.d.ts +47 -3
  21. package/dist/vite.js +81 -20
  22. package/editor/assets/{agent8-BXlYM9rv.js → agent8-DA-I5qhv.js} +1 -1
  23. package/editor/assets/{debug-S42XGP4Y.js → debug-99tEPoGm.js} +1 -1
  24. package/editor/assets/index-Db74LZY-.js +11579 -0
  25. package/editor/index.html +1 -1
  26. package/package.json +1 -1
  27. package/schemas/scene.schema.json +30 -2
  28. package/skills/incanto-3d-character.md +26 -1
  29. package/skills/incanto-editor.md +56 -2
  30. package/skills/incanto-environment.md +30 -12
  31. package/skills/incanto-node-reference.md +6 -1
  32. package/templates-app/beacon-isle-3d/package.json +1 -1
  33. package/templates-app/beacon-isle-3d/src/game.scene.json +6 -1
  34. package/templates-app/molehill-2d/package.json +1 -1
  35. package/templates-app/platformer-2d/package.json +1 -1
  36. package/templates-app/star-survivor/package.json +1 -1
  37. package/templates-app/tps-3d/package.json +1 -1
  38. package/templates-app/village-quest-3d/package.json +1 -1
  39. package/editor/assets/index-DgrgUKno.js +0 -11046
@@ -5,10 +5,10 @@ import { t as Rng } from "./rng-CDOMybym.js";
5
5
  import { c as getNodeSchema, h as didYouMean, p as registerNode } from "./touch-CioTZB-y.js";
6
6
  import { t as createNoise2D } from "./noise-D3nPpmFg.js";
7
7
  import { i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, r as validateAnimations, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-SQa5gIu2.js";
8
- import { $ as Water3D, Q as tolerateUnknownAction, V as effectRng, at as PhysicsBody3D, ct as Node3D, ft as colliderFootDrop, ht as findHealth, it as CharacterBody3D, nt as rejectScale, ot as RigidBody3D, rt as Area3D, st as StaticBody3D, tt as WaterCutout3D } from "./gameplay-D-JuGura.js";
8
+ import { $ as Water3D, Q as tolerateUnknownAction, V as effectRng, at as PhysicsBody3D, ct as Node3D, ft as colliderFootDrop, ht as buildSeaSpectrum, it as CharacterBody3D, mt as seaFftSupported, nt as rejectScale, ot as RigidBody3D, pt as SeaFft, rt as Area3D, st as StaticBody3D, tt as WaterCutout3D, vt as findHealth } from "./gameplay-Bfff_beb.js";
9
9
  import { t as checkSheetGrid } from "./sheet-grid-Cea343VO.js";
10
10
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-BYgD5Edk.js";
11
- import { AdditiveBlending, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CapsuleGeometry, ClampToEdgeWrapping, Color, ConeGeometry, CylinderGeometry, DataTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, Group, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LinearMipmapLinearFilter, LoopOnce, LoopRepeat, MathUtils, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NoColorSpace, NormalBlending, PerspectiveCamera, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, SphereGeometry, SpotLight, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector3, Vector4, VectorKeyframeTrack } from "three";
11
+ import { AdditiveBlending, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CapsuleGeometry, ClampToEdgeWrapping, Color, ConeGeometry, CubeCamera, CylinderGeometry, DataTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, Group, HalfFloatType, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LinearMipmapLinearFilter, LoopOnce, LoopRepeat, MathUtils, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NoColorSpace, NormalBlending, PerspectiveCamera, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, SphereGeometry, SpotLight, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector3, Vector4, VectorKeyframeTrack, WebGLCubeRenderTarget } from "three";
12
12
  import { clone } from "three/addons/utils/SkeletonUtils.js";
13
13
  //#region src/3d/textures.ts
14
14
  /**
@@ -2979,6 +2979,40 @@ const MOVEMENT_STATES = [
2979
2979
  "sneak",
2980
2980
  "dash"
2981
2981
  ];
2982
+ /**
2983
+ * What to play when the scene's `animations` map has no clip for a state.
2984
+ *
2985
+ * A missing entry used to leave whatever was playing BEFORE, which is never
2986
+ * what anyone meant: a character swimming across a bay in a frozen jump pose,
2987
+ * because `swim` was unmapped and the last state was `airborne`. Every state
2988
+ * added after the original five had this — `dash`, `climb`, `sneak`, `crouch`
2989
+ * — and no scene in the repo mapped any of them.
2990
+ *
2991
+ * Each chain ends at `idle`, so any map with one clip in it animates.
2992
+ */
2993
+ const CLIP_FALLBACK = {
2994
+ idle: [],
2995
+ walk: ["idle"],
2996
+ run: ["walk", "idle"],
2997
+ fastRun: [
2998
+ "run",
2999
+ "walk",
3000
+ "idle"
3001
+ ],
3002
+ airborne: ["idle"],
3003
+ swim: ["walk", "idle"],
3004
+ climb: ["walk", "idle"],
3005
+ crouch: ["idle"],
3006
+ sneak: ["walk", "idle"],
3007
+ dash: [
3008
+ "fastRun",
3009
+ "run",
3010
+ "walk",
3011
+ "idle"
3012
+ ]
3013
+ };
3014
+ /** How much of a swimmer's head rides clear of the water (m) — see `swimDepth`. */
3015
+ const HEAD_ABOVE_WATER = .15;
2982
3016
  /** Air control stands down this long after a wall kick, so the kick lands. */
2983
3017
  const WALL_LOCKOUT_SECONDS = .18;
2984
3018
  const ACC_DELTA_TIME = 8;
@@ -3082,10 +3116,20 @@ var CharacterController3D = class extends Node3D {
3082
3116
  fallGravity: { default: 2.5 },
3083
3117
  floatHeight: { default: .01 },
3084
3118
  stepHeight: { default: .35 },
3119
+ slopeLimitDeg: {
3120
+ default: 55,
3121
+ range: {
3122
+ min: 0,
3123
+ max: 90
3124
+ }
3125
+ },
3085
3126
  /** Swimming pace (m/s) in water too deep to stand in; 0 = no swimming (the body sinks). */
3086
3127
  swimSpeed: { default: 1.6 },
3087
3128
  /** How far the body's origin rides BELOW the surface while swimming (m). */
3088
- swimDepth: { default: .35 },
3129
+ swimDepth: {
3130
+ default: null,
3131
+ kinds: ["null", "number"]
3132
+ },
3089
3133
  /** Held: swim DOWN at `swimSpeed`. Absent from the input map = no diving. */
3090
3134
  diveAction: { default: "dive" },
3091
3135
  /** Ladder pace (m/s): push UP into an Area3D in `ladderGroup` and the body climbs it; 0 = no ladders. */
@@ -3246,8 +3290,18 @@ var CharacterController3D = class extends Node3D {
3246
3290
  * is what a drowning pit wants and what every scene got before this.
3247
3291
  */
3248
3292
  swimSpeed = 1.6;
3249
- /** Where the body's origin rides below the surface while swimming (m). */
3250
- swimDepth = .35;
3293
+ /**
3294
+ * How far the body's ORIGIN rides below the surface while swimming (m), or
3295
+ * `null` (the default) to take it from the body: deep enough that the head
3296
+ * rides 15 cm clear and the rest is in the water, which is what treading
3297
+ * water looks like.
3298
+ *
3299
+ * The origin is the middle of the capsule, so a fixed number is only ever
3300
+ * right for one character size. The 0.35 m this used to default to left the
3301
+ * head of a standard 1.68 m rig 0.46 m clear of the surface — a person
3302
+ * floating like a boat, chest and shoulders in the air.
3303
+ */
3304
+ swimDepth = null;
3251
3305
  /** Held: swim down at `swimSpeed`. Missing from the input map is fine. */
3252
3306
  diveAction = "dive";
3253
3307
  /**
@@ -3303,6 +3357,20 @@ var CharacterController3D = class extends Node3D {
3303
3357
  * 200 kg character does not move a seesaw a 70 kg crate tips.
3304
3358
  */
3305
3359
  stepHeight = .35;
3360
+ /**
3361
+ * The steepest ground this character STANDS on, in degrees (0-90).
3362
+ *
3363
+ * At or under it the ground is ground: the character holds its place with no
3364
+ * slide at all, however long you leave it there. Over it the ground stops
3365
+ * counting as ground — no hover spring, no `grounded`, no jump reset — and
3366
+ * the body slips off the face, which is what a slope limit means in every
3367
+ * engine that has one.
3368
+ *
3369
+ * 55° is a person. A mountain goat is 80; something on wheels is 25. `90`
3370
+ * turns the limit off — which is what this controller did before it had one,
3371
+ * and why it slid down a 7° hillside.
3372
+ */
3373
+ slopeLimitDeg = 55;
3306
3374
  mouseLook = true;
3307
3375
  zoomMin = 0;
3308
3376
  zoomMax = 0;
@@ -3499,6 +3567,10 @@ var CharacterController3D = class extends Node3D {
3499
3567
  /** idle | walk | run | fastRun | airborne | swim — drive animations from this. */
3500
3568
  state = "idle";
3501
3569
  grounded = false;
3570
+ /** Slope of the ground under the character, degrees — 0 when there is none. */
3571
+ groundSlopeDeg = 0;
3572
+ /** Its outward normal, or null. `[0, 1, 0]` is flat ground. */
3573
+ groundNormal = null;
3502
3574
  /** In water too deep to stand in — held at the surface by the swim spring. */
3503
3575
  swimming = false;
3504
3576
  /** The head is under the surface (diving, or a wave over it). */
@@ -3528,6 +3600,12 @@ var CharacterController3D = class extends Node3D {
3528
3600
  jumping = false;
3529
3601
  /** Seconds of air control still suppressed after a wall kick. */
3530
3602
  wallLockout = 0;
3603
+ /** What the anti-slide has learned it must keep paying to hold a slope. */
3604
+ slideHold = 0;
3605
+ /** Where the body stood last step, for the anti-slide's position error. */
3606
+ slideFrom = null;
3607
+ /** Seconds since the player last asked this character to move. */
3608
+ stillFor = 0;
3531
3609
  /** The outward normal of the wall being pressed into this step, if any. */
3532
3610
  wallNormal = null;
3533
3611
  get body() {
@@ -3572,6 +3650,8 @@ var CharacterController3D = class extends Node3D {
3572
3650
  let toi = null;
3573
3651
  /** What the probe landed on — the platform, when there is one. */
3574
3652
  let ground = null;
3653
+ /** …and which way its surface faces, for the slope limit and the spring. */
3654
+ let normal = null;
3575
3655
  for (const [ox, oz] of [
3576
3656
  [0, 0],
3577
3657
  [radius, 0],
@@ -3593,20 +3673,42 @@ var CharacterController3D = class extends Node3D {
3593
3673
  if (toi === null || d < toi) {
3594
3674
  toi = d;
3595
3675
  ground = hit.node;
3676
+ const n = hit.normal;
3677
+ normal = n ? [
3678
+ n[0] ?? 0,
3679
+ n[1] ?? 1,
3680
+ n[2] ?? 0
3681
+ ] : null;
3596
3682
  }
3597
3683
  if (ox === 0 && oz === 0) break;
3598
3684
  }
3599
3685
  }
3600
- this.grounded = toi !== null && toi < floatingDis * 2;
3601
- const carry = this.groundCarry(ground, dt);
3602
- const footed = toi !== null && toi < floatingDis * 1.3 && (vel[1] ?? 0) <= .5;
3686
+ const upDot = normal ? Math.max(-1, Math.min(1, normal[1] ?? 1)) : 1;
3687
+ const slopeDeg = toi === null ? 0 : Math.acos(Math.max(upDot, 0)) * 180 / Math.PI;
3688
+ this.groundSlopeDeg = slopeDeg;
3689
+ this.groundNormal = toi === null ? null : normal ?? [
3690
+ 0,
3691
+ 1,
3692
+ 0
3693
+ ];
3694
+ const standable = slopeDeg <= this.slopeLimitDeg;
3695
+ /** The spring's target: `floatHeight` of clearance PERPENDICULAR to the ground. */
3696
+ const holdDis = floatingDis / Math.max(upDot, .35);
3697
+ this.grounded = toi !== null && toi < holdDis * 2 && standable;
3698
+ if (!this.grounded) {
3699
+ this.slideHold = 0;
3700
+ this.slideFrom = null;
3701
+ }
3702
+ const carry = this.groundCarry(standable ? ground : null, dt);
3703
+ const footed = toi !== null && toi < holdDis * 1.3 && standable && (vel[1] ?? 0) <= .5;
3603
3704
  const water = this.swimSpeed > 0 ? this.surfaceAt(pos[0], pos[2]) : null;
3604
3705
  const surface = water === null ? null : water[0];
3605
- const waterline = surface === null ? null : surface - this.swimDepth;
3706
+ const swimDepth = this.swimDepth ?? Math.max(halfHeight + radius - HEAD_ABOVE_WATER, .05);
3707
+ const waterline = surface === null ? null : surface - swimDepth;
3606
3708
  const standY = toi === null ? null : originY - toi + floatingDis + halfHeight;
3607
3709
  const swimming = waterline !== null && surface !== null && surface > pos[1] - halfHeight && waterline !== null && (standY === null || waterline > standY) && !(this.jumping && (vel[1] ?? 0) > .5);
3608
3710
  this.swimming = swimming;
3609
- this.submerged = surface !== null && surface > pos[1] + halfHeight;
3711
+ this.submerged = surface !== null && surface > pos[1] + halfHeight + radius;
3610
3712
  if (swimming) {
3611
3713
  this.jumping = false;
3612
3714
  this.rising = false;
@@ -3757,6 +3859,33 @@ var CharacterController3D = class extends Node3D {
3757
3859
  0,
3758
3860
  -(vel[2] - carry[1]) * DRAG_DAMPING * mass
3759
3861
  ]);
3862
+ this.stillFor = moving ? 0 : this.stillFor + dt;
3863
+ if (this.grounded && !swimming && !climbing && !onLadder && !this.jumping) {
3864
+ const n = this.groundNormal;
3865
+ const nx = n?.[0] ?? 0;
3866
+ const nz = n?.[2] ?? 0;
3867
+ const sinSlope = Math.hypot(nx, nz);
3868
+ if (sinSlope > .001) {
3869
+ const ux = nx / sinSlope;
3870
+ const uz = nz / sinSlope;
3871
+ const drift = (vel[0] - carry[0]) * ux + (vel[2] - carry[1]) * uz;
3872
+ if (this.stillFor < .1) {
3873
+ this.slideHold = 0;
3874
+ this.slideFrom = null;
3875
+ } else {
3876
+ const from = this.slideFrom;
3877
+ const moved = from === null ? 0 : (pos[0] - from[0]) * ux + (pos[2] - from[1]) * uz;
3878
+ this.slideFrom = [pos[0], pos[2]];
3879
+ this.slideHold = Math.max(-2, Math.min(2, this.slideHold + moved / dt * .5));
3880
+ const hold = drift + this.slideHold;
3881
+ if (hold !== 0) body.applyImpulse([
3882
+ -hold * ux * mass,
3883
+ 0,
3884
+ -hold * uz * mass
3885
+ ]);
3886
+ }
3887
+ }
3888
+ }
3760
3889
  let springToi = toi;
3761
3890
  if (this.stepHeight > 0 && moving && toi !== null && !this.jumping && !swimming && !climbing && (vel[1] ?? 0) <= .5) {
3762
3891
  const ahead = radius + .12;
@@ -3776,7 +3905,7 @@ var CharacterController3D = class extends Node3D {
3776
3905
  }
3777
3906
  }
3778
3907
  if (springToi !== null && springToi < floatingDis * 2 && (!this.jumping || mantling) && !swimming && !climbing && !onLadder && (vel[1] ?? 0) < 2) {
3779
- const spring = SPRING_K * (floatingDis - springToi) - vel[1] * SPRING_DAMPING;
3908
+ const spring = SPRING_K * (holdDis - springToi) - vel[1] * SPRING_DAMPING;
3780
3909
  body.applyImpulse([
3781
3910
  0,
3782
3911
  spring * mass,
@@ -3889,7 +4018,7 @@ var CharacterController3D = class extends Node3D {
3889
4018
  this.wallLockout = WALL_LOCKOUT_SECONDS;
3890
4019
  this.emit("wallJumped", wall);
3891
4020
  } else if (wantsJump && !onLadder && !(swimming && this.submerged) && (footed || swimming || this.coyoteLeft > 0 || this.jumpsUsed < this.maxJumps)) {
3892
- const hopVel = swimming ? Math.max(this.jumpVelocity, Math.sqrt(2 * this.sceneGravity() * (this.swimDepth + halfHeight + this.stepHeight + .05))) : 0;
4021
+ const hopVel = swimming ? Math.max(this.jumpVelocity, Math.sqrt(2 * this.sceneGravity() * (this.effectiveSwimDepth() + halfHeight + this.stepHeight + .05))) : 0;
3893
4022
  const jumpVel = swimming ? hopVel : this.jumpVelocity * (1 + (this.sprintJumpMultiplier - 1) * intensity);
3894
4023
  if (swimming) this.swimHop = true;
3895
4024
  body.linearVelocity = [
@@ -3954,7 +4083,14 @@ var CharacterController3D = class extends Node3D {
3954
4083
  const next = dashing && !swimming ? "dash" : swimming ? "swim" : this.onLadder || climbing ? "climb" : this.crouching && !airborne ? moving ? "sneak" : "crouch" : movementState(!airborne, moving ? intensity : 0);
3955
4084
  if (next !== this.state) {
3956
4085
  this.state = next;
3957
- const clip = this.animations[next];
4086
+ let clip = this.animations[next];
4087
+ if (!clip) for (const alt of CLIP_FALLBACK[next]) {
4088
+ const c = this.animations[alt];
4089
+ if (c) {
4090
+ clip = c;
4091
+ break;
4092
+ }
4093
+ }
3958
4094
  if (clip && this.skinPath !== "") {
3959
4095
  const skin = this.getNodeOrNull(this.skinPath);
3960
4096
  if (skin && "animation" in skin) skin.animation = clip;
@@ -4036,6 +4172,14 @@ var CharacterController3D = class extends Node3D {
4036
4172
  const g = this.tree?.engine?.scene?.physics?.gravity;
4037
4173
  return Math.abs(g?.[1] ?? STANDARD_G) || STANDARD_G;
4038
4174
  }
4175
+ /** `swimDepth`, or what it is derived to be for this body (see the prop). */
4176
+ effectiveSwimDepth() {
4177
+ if (this.swimDepth !== null) return this.swimDepth;
4178
+ const body = this.parent;
4179
+ const radius = body?.collider?.radius ?? .32;
4180
+ const halfHeight = (body?.collider?.height ?? 1) / 2;
4181
+ return Math.max(halfHeight + radius - HEAD_ABOVE_WATER, .05);
4182
+ }
4039
4183
  surfaceAt(x, z) {
4040
4184
  if (this.waterScanIn <= 0) {
4041
4185
  this.waters.length = 0;
@@ -10385,6 +10529,15 @@ uniform float uRippleStrength;
10385
10529
  uniform float uFlowRate;
10386
10530
  uniform float uSparkle;
10387
10531
  uniform float uFresnelScale;
10532
+ // the river's own sky cube (river-3d.ts): what the surface mirrors instead of
10533
+ // the painted three-colour gradient, which stays as the headless fallback
10534
+ uniform samplerCube uEnvironmentMap;
10535
+ uniform float uUseEnvironmentMap;
10536
+ // the live ripple layer (fft-gpu.ts), a 6 m patch in the ribbon's own frame
10537
+ uniform float uRippleOn;
10538
+ uniform sampler2D uRippleNormal;
10539
+ uniform float uRipplePatch;
10540
+ uniform float uRippleGain;
10388
10541
  /**
10389
10542
  * Rocks standing in the current: xz = centre, z = radius, w = how much of the
10390
10543
  * water they break (0 = drowned and quiet, 1 = the crest stands proud).
@@ -10427,6 +10580,32 @@ float riverStreak(vec2 p) {
10427
10580
  return riverNoise(q) * 0.5 + riverNoise(q * 3.3 + 7.3) * 0.5;
10428
10581
  }
10429
10582
 
10583
+ /** water-that-reads-as-water: the FINE wavelets — the two ripple octaves are
10584
+ * a hand-span and up, so at the range a player actually stands from a creek
10585
+ * the surface tilted in soft blobs and nothing on it could ever face the sun
10586
+ * (the gel read). A third, much shorter field, differenced at its own step
10587
+ * (the ripple's 0.35 step is wider than these wavelets), gives the normal the
10588
+ * facets that glitter and that shatter the reflection; it fades out where a
10589
+ * pixel would integrate it into speckle. Squeezed downstream like the rest. */
10590
+ // Karis' split-sum fit: what a ROUGH surface reflects at this angle — see the
10591
+ // sea-state fresnel note in shaders.ts. A river at a grazing view is a field
10592
+ // of wavelets, not a mirror, and the flat-Schlick sheet of sky it used to wear
10593
+ // is the plastic look this replaces.
10594
+ vec2 envBRDFApprox(float roughness, float NoV) {
10595
+ const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
10596
+ const vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
10597
+ vec4 r = roughness * c0 + c1;
10598
+ float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
10599
+ return vec2(-1.04, 1.04) * a004 + r.zw;
10600
+ }
10601
+
10602
+ vec2 riverFineSlope(vec2 p) {
10603
+ vec2 q = vec2(p.x * 10.7, p.y * 3.6) + 5.7;
10604
+ const float e = 0.08;
10605
+ float h = riverNoise(q);
10606
+ return vec2(riverNoise(q + vec2(e, 0.0)) - h, riverNoise(q + vec2(0.0, e)) - h) / e;
10607
+ }
10608
+
10430
10609
  void main() {
10431
10610
  float speed = vRiver.x;
10432
10611
  float column = vRiver.y;
@@ -10500,7 +10679,26 @@ void main() {
10500
10679
  // the gradient is a NOISE slope, not a water slope: clamp it before it tilts
10501
10680
  // the normal past a real wavelet (an over-driven normal throws fresnel to its
10502
10681
  // maximum everywhere and the river turns into a sheet of white sky)
10503
- vec2 slopeXY = clamp(ripple.yz * uRippleStrength * lively, vec2(-0.5), vec2(0.5));
10682
+ float camDist = length(cameraPosition - vWorldPosition);
10683
+ float fineFade = (1.0 - smoothstep(6.0, 34.0, camDist)) * lively;
10684
+ vec2 fine = vec2(0.0);
10685
+ if (fineFade > 0.01) {
10686
+ vec2 fineA = riverFineSlope(base - vec2(travel * pA, 0.0));
10687
+ vec2 fineB = riverFineSlope(base - vec2(travel * pB, 0.0));
10688
+ // under the ripple FFT the noise keeps only the sub-pixel flash
10689
+ fine = mix(fineA, fineB, blend) *
10690
+ (0.028 * fineFade * (0.5 + 0.5 * uSparkle) * (uRippleOn > 0.5 ? 0.45 : 1.0));
10691
+ }
10692
+ // the ripple FFT, carried downstream in the ribbon's metres with the same
10693
+ // dual-phase crossfade the noise uses (a field scrolled at a speed that
10694
+ // varies along the river smears; two half-period copies do not)
10695
+ if (uRippleOn > 0.5) {
10696
+ float travelM = speed / max(uFlowRate, 0.001);
10697
+ vec2 rA = texture2D(uRippleNormal, (vFlowUv - vec2(travelM * pA, 0.0)) / uRipplePatch, -1.0).xy;
10698
+ vec2 rB = texture2D(uRippleNormal, (vFlowUv - vec2(travelM * pB, 0.0)) / uRipplePatch, -1.0).xy;
10699
+ fine += mix(rA, rB, blend) * (uRippleGain * lively);
10700
+ }
10701
+ vec2 slopeXY = clamp(ripple.yz * uRippleStrength * lively + fine, vec2(-0.5), vec2(0.5));
10504
10702
  vec3 normal = normalize(vSurfaceNormal - dir3 * slopeXY.x - perp3 * slopeXY.y);
10505
10703
 
10506
10704
  vec3 viewDir = normalize(cameraPosition - vWorldPosition);
@@ -10604,31 +10802,53 @@ void main() {
10604
10802
  // eye is near the water — hold its dark tone well past the horizon line
10605
10803
  vec3 sky = mix(uBankColor, uHorizonColor, smoothstep(0.05, 0.34, reflectDir.y));
10606
10804
  sky = mix(sky, uSkyColor, smoothstep(0.34, 0.85, reflectDir.y));
10607
- float fresnel = clamp(
10608
- uFresnelScale * pow(1.0 - clamp(dot(normal, viewDir), 0.0, 1.0), 5.0),
10609
- 0.0,
10610
- 0.82
10611
- );
10805
+ if (uUseEnvironmentMap > 0.5) {
10806
+ // the REAL sky, from the river's own cube — near-clipped, so the lowest
10807
+ // rays (which meet the bank a few metres off) keep the bank tone
10808
+ vec3 lifted = vec3(reflectDir.x, max(reflectDir.y, 0.06), reflectDir.z);
10809
+ vec3 cube = min(textureCube(uEnvironmentMap, lifted).rgb, vec3(2.5));
10810
+ sky = mix(uBankColor, cube, smoothstep(0.02, 0.2, reflectDir.y));
10811
+ }
10812
+ float rough = clamp(0.045 + 0.28 * smoothstep(8.0, 140.0, camDist), 0.04, 0.35);
10813
+ float NoVf = clamp(dot(normal, viewDir), 0.0, 1.0);
10814
+ float fresnel = uFresnelScale * (0.02 + 0.98 * pow(1.0 - NoVf, 5.0));
10815
+ {
10816
+ vec2 fRough = envBRDFApprox(rough, NoVf);
10817
+ vec2 fSmooth = envBRDFApprox(0.02, NoVf);
10818
+ fresnel *= clamp((0.02 * fRough.x + fRough.y) / max(0.02 * fSmooth.x + fSmooth.y, 1e-3), 0.0, 1.35);
10819
+ }
10820
+ fresnel = clamp(fresnel, 0.0, 0.82);
10612
10821
 
10613
10822
  vec3 sunDir = normalize(uSunDirection);
10614
10823
  vec3 halfVec = normalize(sunDir + viewDir);
10615
- float ndh = max(dot(normal, halfVec), 0.0);
10616
- // Two lobes: a tight glitter spark, plus a whisper of broad sheen so the
10617
- // river still READS as a moving surface from across the valley, where the
10618
- // sparkle is smaller than a pixel.
10619
- //
10620
- // The broad lobe is the dangerous one. At exponent 18 it stays near 1 across
10621
- // a huge stretch of a near-flat surface, and at 0.22 · 1.6 that is a third of
10622
- // full white ADDED to every one of those fragments a sheet of white lying
10623
- // over the water that reads as fog and swallows the bed, the foam and the
10624
- // rocks alike. Narrow it and cut its weight; the sparkle carries the sun.
10625
- float spec = (pow(ndh, 160.0) + pow(ndh, 34.0) * 0.07) * uSunIntensity;
10626
- // glitter: the same advected field gates the highlight so it breaks into
10627
- // travelling sparks instead of one smooth sheen
10628
- float glint = smoothstep(0.55, 0.95, ripple.x);
10629
- spec *= 0.45 + 0.55 * glint * uSparkle;
10824
+ // ── the sun as a microfacet highlight ──────────────────────────────────
10825
+ // water-that-reads-as-water: the two Blinn-Phong lobes (160 + a whisper of
10826
+ // 34) were a hand-shaped sheen gated by the ripple field, capped at 1.2 —
10827
+ // a running creek under a 40°-high sun showed no sun at all. One GGX lobe
10828
+ // over the wavelet normal: resolved facets near the eye flash (the glitter
10829
+ // a riffle throws), and with distance the roughness grows so the same lobe
10830
+ // widens into the soft sheen a river shows from across the valley. HDR by
10831
+ // design the sun's reflection is the brightest thing on the water and
10832
+ // the bloom pass reads it. RIVER_SUN_RADIANCE scales the sun against the
10833
+ // unit-ish sky gradient this shader mirrors.
10834
+ const float RIVER_SUN_RADIANCE = 30.0;
10835
+ float NoL = clamp(dot(normal, sunDir), 0.0, 1.0);
10836
+ float NoV = max(dot(normal, viewDir), 1e-3);
10837
+ float NoH = clamp(dot(normal, halfVec), 0.0, 1.0);
10838
+ float LoH = clamp(dot(sunDir, halfVec), 0.0, 1.0);
10839
+ float a2 = rough * rough;
10840
+ float dD = NoH * NoH * (a2 - 1.0) + 1.0;
10841
+ float D = a2 / (3.14159265 * dD * dD);
10842
+ float gV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
10843
+ float gL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
10844
+ float Vis = 0.5 / max(gV + gL, 1e-4);
10845
+ float Fh = 0.02 + 0.98 * pow(1.0 - LoH, 5.0);
10846
+ float spec = D * Vis * Fh * NoL * RIVER_SUN_RADIANCE * uSunIntensity;
10847
+ spec *= 0.5 + 0.5 * uSparkle; // the ripples dial still tunes how much sun the water throws
10630
10848
  spec *= 1.0 - foam * 0.85; // froth is rough — it kills the mirror
10631
- spec = min(spec, 1.2); // a highlight, never a white-out
10849
+ // a facet under a pixel aliases into a lone flash: the roughness ramp
10850
+ // widens the lobe out there, the cap catches the last isolated ones
10851
+ spec = min(spec, mix(5.0, 1.2, smoothstep(30.0, 200.0, camDist)));
10632
10852
 
10633
10853
  fresnel *= 1.0 - 0.85 * fall; // froth scatters; it does not reflect
10634
10854
  vec3 color = mix(body, sky, fresnel);
@@ -10728,6 +10948,21 @@ const FOAM_COLOR = "#e9f2ef";
10728
10948
  /** Per-frame scratch (no allocation in the render path). */
10729
10949
  const rockCamScratch = new Vector3();
10730
10950
  /** Fixed look constants — the props stay intent-level, these carry the craft. */
10951
+ /** The river's own sky: a slow cube of the world beyond its banks (the sky,
10952
+ * the far hills), refreshed every couple of seconds — what the surface
10953
+ * mirrors instead of a painted three-colour gradient. Near-clipped so the
10954
+ * banks themselves, metres away, never smear across it. */
10955
+ const RIVER_SKY_RESOLUTION = 128;
10956
+ const RIVER_SKY_INTERVAL = 2e3;
10957
+ const RIVER_SKY_NEAR = 60;
10958
+ /** The live ripple layer (see Water3D): a 6 m spectrum patch advected with
10959
+ * the current in the ribbon's own frame. */
10960
+ const RIVER_RIPPLE_SIZE = 128;
10961
+ const RIVER_RIPPLE_PATCH = 6;
10962
+ const RIVER_RIPPLE_FETCH = 150;
10963
+ const RIVER_RIPPLE_SEED = 4242;
10964
+ /** Ripple rms slope per unit of `ripples`. */
10965
+ const RIVER_RIPPLE_SLOPE = .07;
10731
10966
  const LOOK = {
10732
10967
  /** Pattern units per meter of flow space. */
10733
10968
  rippleScale: .85,
@@ -10870,6 +11105,12 @@ var River3D = class River3D extends Node3D {
10870
11105
  0
10871
11106
  ];
10872
11107
  _bodies = [];
11108
+ cubeTarget = null;
11109
+ cubeCamera = null;
11110
+ lastSkyAt = 0;
11111
+ rippleFft = null;
11112
+ rippleSpectrum = null;
11113
+ fftUnsupported = false;
10873
11114
  /** Loader hook: a malformed river fails at LOAD, not as an invisible ribbon. */
10874
11115
  static validateJson(node) {
10875
11116
  rejectScale(node, "the flow query and its collider both");
@@ -10936,8 +11177,68 @@ var River3D = class River3D extends Node3D {
10936
11177
  this.rockScanAt = this.time;
10937
11178
  this.scanRocks();
10938
11179
  }
10939
- const u = this._ensureObject3D().material.uniforms;
11180
+ const mesh = this._ensureObject3D();
11181
+ const u = mesh.material.uniforms;
10940
11182
  this.syncRocks(u, ctx.camera.getWorldPosition(rockCamScratch));
11183
+ if (typeof ctx.gl?.extensions?.has === "function") {
11184
+ this.syncSky(ctx, mesh, u);
11185
+ this.syncRipple(ctx, u);
11186
+ }
11187
+ }
11188
+ /** The sky cube: the world beyond the banks, from above the channel's middle. */
11189
+ syncSky(ctx, mesh, u) {
11190
+ if (!this.cubeTarget) {
11191
+ this.cubeTarget = new WebGLCubeRenderTarget(RIVER_SKY_RESOLUTION, {
11192
+ generateMipmaps: true,
11193
+ minFilter: LinearMipmapLinearFilter,
11194
+ type: HalfFloatType
11195
+ });
11196
+ this.cubeCamera = new CubeCamera(RIVER_SKY_NEAR, 4e3, this.cubeTarget);
11197
+ this.lastSkyAt = 0;
11198
+ }
11199
+ const now = Date.now();
11200
+ if (this.lastSkyAt === 0 || now - this.lastSkyAt >= RIVER_SKY_INTERVAL) {
11201
+ this.lastSkyAt = now;
11202
+ const b = this.ringBounds();
11203
+ const [wx, wy, wz] = this.world;
11204
+ const cam = this.cubeCamera;
11205
+ cam.position.set(wx + (b.minX + b.maxX) / 2, wy + 3, wz + (b.minZ + b.maxZ) / 2);
11206
+ const wasVisible = mesh.visible;
11207
+ mesh.visible = false;
11208
+ cam.update(ctx.gl, ctx.scene);
11209
+ mesh.visible = wasVisible;
11210
+ }
11211
+ if (u.uEnvironmentMap) u.uEnvironmentMap.value = this.cubeTarget.texture;
11212
+ if (u.uUseEnvironmentMap) u.uUseEnvironmentMap.value = 1;
11213
+ }
11214
+ /** The ripple FFT, advected by the fragment in the ribbon's own frame. */
11215
+ syncRipple(ctx, u) {
11216
+ if (this.fftUnsupported) return;
11217
+ if (!this.rippleFft) {
11218
+ if (!seaFftSupported(ctx.gl)) {
11219
+ this.fftUnsupported = true;
11220
+ return;
11221
+ }
11222
+ this.rippleFft = new SeaFft(RIVER_RIPPLE_SIZE, RIVER_RIPPLE_PATCH);
11223
+ this.rippleSpectrum = buildSeaSpectrum({
11224
+ size: RIVER_RIPPLE_SIZE,
11225
+ patch: RIVER_RIPPLE_PATCH,
11226
+ wind: 3,
11227
+ windDirectionDeg: 90,
11228
+ seed: RIVER_RIPPLE_SEED,
11229
+ choppiness: .3,
11230
+ fetch: RIVER_RIPPLE_FETCH,
11231
+ modes: 0
11232
+ });
11233
+ this.rippleFft.setSpectrum(this.rippleSpectrum);
11234
+ }
11235
+ this.rippleFft.update(ctx.gl, this.time);
11236
+ if (u.uRippleNormal) u.uRippleNormal.value = this.rippleFft.normal;
11237
+ if (u.uRippleOn) u.uRippleOn.value = 1;
11238
+ if (u.uRippleGain && this.rippleSpectrum) {
11239
+ const rms = this.rippleSpectrum.rmsSlope;
11240
+ u.uRippleGain.value = rms > 0 ? RIVER_RIPPLE_SLOPE * this.ripples / rms : 0;
11241
+ }
10941
11242
  }
10942
11243
  /**
10943
11244
  * @internal Water surface height over a world point, or null where this
@@ -11026,6 +11327,11 @@ var River3D = class River3D extends Node3D {
11026
11327
  const mesh = this._ensureObject3D();
11027
11328
  mesh.geometry?.dispose();
11028
11329
  mesh.material?.dispose?.();
11330
+ this.cubeTarget?.dispose();
11331
+ this.cubeTarget = null;
11332
+ this.cubeCamera = null;
11333
+ this.rippleFft?.dispose();
11334
+ this.rippleFft = null;
11029
11335
  super.free();
11030
11336
  }
11031
11337
  widthProfile() {
@@ -11185,6 +11491,12 @@ var River3D = class River3D extends Node3D {
11185
11491
  uRockCount: { value: 0 },
11186
11492
  uRocks: { value: Array.from({ length: ROCK_SLOTS }, () => new Vector4()) },
11187
11493
  uFresnelScale: { value: LOOK.fresnelScale },
11494
+ uEnvironmentMap: { value: null },
11495
+ uUseEnvironmentMap: { value: 0 },
11496
+ uRippleOn: { value: 0 },
11497
+ uRippleNormal: { value: null },
11498
+ uRipplePatch: { value: RIVER_RIPPLE_PATCH },
11499
+ uRippleGain: { value: 0 },
11188
11500
  uStandingAmp: { value: LOOK.standingAmp },
11189
11501
  uGlideAmp: { value: LOOK.glideAmp }
11190
11502
  },