Skip to content

模拟大海(学习Gerstner波)

<template>
  <div>
    <div><a target="_blank" href="https://zhuanlan.zhihu.com/p/623569022">GerstnerWave原理</a></div>
    <div><a target="_blank" href="https://gameidea.org/2023/12/01/3d-ocean-shader-using-gerstner-waves/">Gerstner Waves</a></div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <canvas v-if="isRunning" id="shaderSea1" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import { 
  CustomMaterial 
} from 'babylonjs-materials'
import {
  Engine,
  Scene,
  Texture,
  HemisphericLight,
  MeshBuilder,
  Effect,
  ShaderMaterial,
  Color4,
  ArcRotateCamera,
  Vector3,
  Color3,
  StandardMaterial
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

 let sceneResources: any, adt: any
let uTime = 0.0

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

const initScene = async () => {
  const ele = document.getElementById("shaderSea1") as any

  ele.addEventListener('wheel', function(event: any) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = false

  const camera = new ArcRotateCamera('camera', -Math.PI / 1.5, Math.PI / 2.2, 15, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 1
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(160, 160, -160))

  const createLight = () => {
    const light = new HemisphericLight('light', new Vector3(40, 40, 40), scene)
    light.direction = new Vector3(1.0, 0.0, 1.0)
    light.diffuse = new Color3(1.0, 0.95, 0.8)
    return light
  }

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }

  const createGround = () => {
    const ground = MeshBuilder.CreateGroundFromHeightMap(
      "heightMap", 
      "/images/heightMap.png", 
      { width: 150, height: 150, subdivisions: 500, maxHeight: 80 }
    )
    const mat = new CustomMaterial('grass', scene)
    mat.diffuseTexture = new Texture('/images/ground.jpg', scene)
    ground.material = mat
    return ground
  }

  const createSphere = () => {
    const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 10 }, scene)
    const sphereMat = new StandardMaterial('sphere')
    sphereMat.diffuseColor = new Color3(1.0, 0.6, 0.2)
    sphere.material = sphereMat
    sphere.position.x = 70
    sphere.position.y = 70
    sphere.position.z = 70
  }

  const createSphereShader = () => {
    Effect.ShadersStore['customShaderVertexShader'] = `
      precision highp float;

      struct GerstnerResult {
        vec3 position;
        vec3 normal;
      };

      attribute vec3 position;
      attribute vec2 uv;

      uniform mat4 world; // 世界矩阵
      uniform vec3 cameraPosition; // 相机位置
      uniform mat4 worldViewProjection; // 投影


      uniform sampler2D textureSampler;
      uniform float uTime;
      uniform vec3 uLightDirection; // 光照参数
      uniform vec3 uLightColor; // 光照参数

      varying vec3 vColor;

      // fract的返回值在 0 ~ 1
      float random(vec2 uv) {
        return fract(sin(dot(uv.xy, vec2(12.354121, 91.321))) * 452361.21321);
      }

      float noise(vec2 uv) {
        vec2 i = floor(uv); // 将输入的二维向量 uv 向下取整,得到包含 uv 点所在网格块的整数坐标 i。这是为了确定 uv 点位于哪个 2D 网格单元内。
        vec2 f = fract(uv); // 得到 uv 的小数部分,即 uv 点在其所在网格单元内的相对位置 f。f 的范围是 [0, 1)。

        // 2d 块的四个角,这四行计算了 uv 所在 2D 块的四个角上的随机值。
        float a = random(i); // 当前网格块左下角的随机值 a。这里使用 random 函数和网格块的整数坐标 i 作为输入。
        float b = random(i + vec2(1.0, 0.0)); // 当前网格块右下角的随机值 b。通过给 i 的 x 坐标加 1,可以定位到相邻的右侧网格块的左下角(因为 i 是整数坐标,加 1 后仍然是整数坐标)。
        float c = random(i + vec2(0.0, 1.0)); // 当前网格块左上角的随机值 c。通过给 i 的 y 坐标加 1,可以定位到相邻的上侧网格块的左下角。
        float d = random(i + vec2(1.0, 1.0)); // 当前网格块右上角的随机值 d。通过同时给 i 的 x 和 y 坐标加 1,可以定位到相邻的右上角网格块的左下角(或者说当前网格块的右上角,取决于如何看待相邻和当前的关系)。
        
        vec2 u = f * f * (3.0 - 2.0 * f); // 计算一个平滑的插值因子 u,用于在四个角点之间进行平滑过渡。f * f * (3.0 - 2.0 * f) 是一个三次插值函数,在 [0, 1] 区间内生成一个平滑的曲线,用于在四个噪声值之间进行插值。

        return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; // 使用 mix 函数和之前计算的插值因子 u 来在水平方向(x 轴)上插值 a 和 b,并手动计算垂直方向(y 轴)以及斜对角方向的插值,得到最终的噪声值。这个计算考虑了所有四个角点的贡献,并通过插值因子 u 来平滑过渡,从而生成一个平滑的二维噪声。
      }

      float fbm(vec2 uv) {
        float value = 0.0;
        float amplitude = 0.75; // 振幅
        float frequency = 0.4; // 频率

        for (int i = 0; i < 6; i++) {
          value += amplitude * noise(uv) * frequency;
          uv *= 3.0;
          amplitude *= 0.5;
        }

        return value;
      }

      GerstnerResult getGerstner(float x, float y, float z, float time) {
        // ---------------------------------------------------------
        // 法线相关的计算,用于光照。本案例使用y的高度来显示海面的白色和蓝色,没用到法线
        // ---------------------------------------------------------
        // 以sin函数为例,求P点的切线
        // P(x, sin(x))   --->   f(x)=sin(x)  --->   f'(x)=cos(x)
        // 带入P(π/2, 1),求得 f'(π/2)=0,即P点的斜率为0
        // 对x求导的结果为1,最后得(1, 0),即切线的方向向量(切线的方向向量可以通过导数来确定)(切线的方向向量可以表示为 (1, dy/dx))
        // ---------------------------------------------------------
        // 将曲线表示为参数方程。设 x=t,y=f(t),那么曲线可以表示为 r(t)=(t,f(t))。在 t=t0时,对应的点是 (t0,f(t0))。
        // 切向量是曲线在该点的导数
        // r′(t)=(dx/dt, dy/dt)=(1,f′(t))
        // 这里, dx/dt=1 是因为 x=t,所以 dx/dt=1
        // ---------------------------------------------------------
        // 假设 y=x^2,在 x=1 处:
        // 斜率  dy/dx=2x,在 x=1 时为 2
        // 切向量可以是 (1,2)
        // 这意味着在 x 方向移动 1,y 方向移动 2,与斜率一致
        // ---------------------------------------------------------
        // 如果曲线不是以 x=t 参数化,比如 x=t^2,y=t^3,那么
        // dx/dt=2t,dy/dt=3(t^2)
        // 切向量为(2t, 3(t^2)),斜率为3(t^2)/2t=(3/2)t
        // 此时需要归一化,比如有一个方向向量 v=(2,3)(例如,在 t=1 时,x=t^2, y=t^3 的切向量为 (2⋅1,3⋅12)=(2,3))
        // 计算模:∥v∥= (2*2 + 3*3)^0.5 = 13^0.5
        // 归一化后 u=(2/(13^0.5), 3/(13^0.5))
        // u 是一个单位向量,方向与 v 相同
        // ---------------------------------------------------------
        // x轴的切线的方向向量(dx/dx, dy/dx, dz/dx)
        // z轴的切线的方向向量(dx/dz, dy/dz, dz/dz)
        // 法线是 N = Z 叉乘 X
        // 为什么是 Z 叉乘 X ?
        // 规定大拇指,食指和中指分别是x,y,z方向,伸出左手,本案例的 useRightHandedSystem 是使用左手定则的
        // 那么就要使用左手螺旋定则确定切线和副切线的叉乘顺序
        // 接着再伸出左手握拳,由于已经海面需要的法线方向朝上,因此大拇指朝上指向法线方向,发现四指的旋向从上往下看是顺时针旋转,由B旋转到T,所以叉乘顺序是B×T
        // 一定要注意使用左右手螺旋定则与左右坐标系有关,右手系要使用右手定则
        // ---------------------------------------------------------
        // 最终计算如下:
        // 设模型空间顶点变换到世界空间后的位置(x, y, z)
        // 经过SineWave偏移后顶点的位置为(x, y + A * sin(k * x - omega * t), z)
        // z的切线方向为(0, 0, 1),所以只要计算x的切线方向。z是这样的结果,是因为把摄像机绕y轴顺时针旋转90看这个正弦波,会发现z方向都是平的
        // X.x = dx/dx = x' = 1
        // X.y = dy/dx = (y + a * sin(k * x - omega * t))' = a * k * cos(k * x - omega * t)
        // X.z = dz/dx = 0' = 0
        // X = (1, a * k * cos(k * x - omega * t), 0)
        // Z = (0, 0, 1)
        // N = Z 叉乘 X
        // vec3 x = vec3(1, a * k * cos(k * x - omega * t), 0)
        // vec3 z = vec3(0, 0, 1)
        // vec3 n = normalize(cross(z, x))
        // ---------------------------------------------------------




        // ---------------------------------------------------------
        // direction = vec4(1, 1, 0, 0)
        // dir = normalize(direction.xy)
        // value = k * D_x * x + k * D_y * z - ω * t  ---> dot(dir * k, xz) - ω * t
        // P_x = x + A * D_x * cos(value)
        // P_y = A * sin(value)
        // P_z = z + A * D_y * cos(value)
        // ---------------------------------------------------------
        // speed 是波的速度(自己定义)
        // A 是波的振幅,表示波浪的高低起伏程度(自己定义)
        // λ 是波长(自己定义)
        // t 是时间,表示当前时刻(外部传参)
        // k 是波数,与波长λ的关系为 k = 2π / λ ,波长是波峰到波峰的距离
        // D 是波的传播方向向量(方向余弦),表示波的传播方向,通常是一个单位向量
        // frequency 是频率,f = speed / λ;   f = 1 / T
        // ω 是角频率,与波的周期T的关系为 ω = 2π / T ,周期是波完成一个完整周期的时间
        // ---------------------------------------------------------
        // 振幅A:决定了波浪的高低起伏程度。振幅越大,波浪越高
        // 波数k:与波长成反比,波长越长,波数越小
        // 传播方向向量D:决定了波的传播方向。例如,如果D=(1,0),则波沿x轴正方向传播
        // 角频率ω:与波的周期成反比,周期越短,角频率越高
        // ---------------------------------------------------------




        vec3 xyz = vec3(x, y, z);


        // 初始化法线为垂直向上的向量,法线的作用是光照,因为即使顶点改变了,但是法线不会改变
        // X和Z也需要累加
        // 每个波对表面的影响是独立的,但最终的表面状态是所有波的叠加结果
        // 这意味着每个波对切线和副切线的贡献也需要逐波累加,而不是在所有波的位置影响计算完成后才统一计算
        vec3 normalX = vec3(0.0); // 切线(Tangent,T)
        vec3 normalZ = vec3(0.0); // 副切线(Binormal,B)


        // 这里是单个的波
        // float A = 0.8;
        // float speed = 10.0;
        // float waveLength = 6.0; // 波长影响波的叠加,比如0.2会多个波叠到一起,太大会呈现一条线,所以尽量找合适的数值
        // float k = 2.0 * 3.14 / waveLength;
        // float f = speed / waveLength;
        // float omega = 2.0 * 3.14 / f;
        // float value = dot(dir * k, vec2(x, z)) - omega * time;
        // xyz.x = x + A * dir.x * cos(value);
        // xyz.y = A * sin(value);
        // xyz.z = z + A * dir.y * cos(value);

        const int wavesCount = 10;

        for(int i = 0; i < wavesCount; i++) {
          float step = float(i) + 0.212;

          // 主浪方向 + 有限角度扩散
          // 0.0 = 沿X轴正方向传播,浪尖沿Z轴(南北方向)垂直排列
          // 如果全部是 0~2π 随机,各方向的挤压力互相抵消,看不到清晰的垂直浪尖
          float dominantAngle = 0.0;
          float spread = 3.1415926 * 0.5; // ±45度扩散,保留主方向感
          float angle = dominantAngle + (random(vec2(float(i), float(i) + 1.1234123)) - 0.5) * spread;
          vec2 dir = vec2(cos(angle), sin(angle));
          
          float A = random(vec2(step + 0.134, step + 0.42)) * 0.2 + 0.2;
          float waveLength = random(vec2(step + 0.134, step + 0.442)) * 15.0 + 10.0;
          // float speed = random(vec2(step + 0.134, step + 0.2)) * 2.0 + 1.2;

          // 固定的计算公式和值
          float k = 2.0 * 3.14 / waveLength;
          // float f = speed / waveLength; // 频率
          // float omega = 2.0 * 3.14 * f; // 等于 k * speed
          float omega = sqrt(9.8 * k); // sqrt(g * k)后,短波(大k)频率快、长波(小k)频率慢,不同波之间存在相位漂移,叠加后自然形成"一组波从无到有、到达峰值、再消退"的波群包络——不需要任何额外的包络函数,纯粹是物理干涉的结果。
          float value = dot(dir * k, vec2(x, z)) - omega * time;
          // 标准 Gerstner 波公式里的 Q(steepness,陡峭度) 参数,只加在水平分量上,垂直分量 y 不加
          float steepness = 0.8; // 全局控制整体陡峭程度,0~1
          // Σ (Q_i × k_i × A_i) = steepness × N / N = steepness ≤ 1
          // k 大(短波)的波,Q 自动分配得小;A 大(高振幅)的波,Q 也自动更小 —— 天然防止自交,同时通过一个 steepness 参数统一控制整体波形的尖锐程度。
          float Q = steepness / (float(wavesCount) * k * A);

          xyz.x += Q * A * dir.x * cos(value);
          xyz.y += A * sin(value);
          xyz.z += Q * A * dir.y * cos(value);


          // T=dP/dx
          // Tx=dPx/dx   --->   dx * k * a * (-sin(value))
          // Ty=dPy/dx   --->   k * a * cos(value)
          // Tz=dPz/dx   --->   dy * k * a * (-sin(value))
          // ---------------------------------------------------------
          // 为什么会得到 normalX.x += dir.x * dir.x * k * A * -sin(value); 这个方程
          // 首先,Px的计算方式是 Px = x + dir.x * a * cos(value)
          // 对x求偏导 dPx / dx = 1 + d(dir.x * a * cos(value)) / dx
          // 由于 dir.x 和 a 是常数,且 value 是 x 的函数,可以进一步计算
          // d(dir.x * a * cos(value)) / dx   --->    dir.x * a * d(cos(value)) / dx 
          // 根据链式法则:
          // d(cos(value)) / dx    --->    -sin(value) * d(value) / dx
          // value 可以表示为:
          // value = k * (dir.x * x + dir.y * z) - omega * t
          // 其中,k 是波数,omega 是角频率,t 是时间
          // 对 value 求 x 的偏导数:
          // d(value) / dx = k * dir.x
          // 将上面的式子带入前面的表达式,得到:
          // d(cos(value)) / dx = -sin(value) * dir.x * k
          // 因此:
          // dPx / dx = 1 + dir.x * a * (-sin(value) * k * dir.x)   --->   dPx / dx = 1 - dir.x * a * sin(value) * k * dir.x
          // 这里的 1 表示初始的切线方向在 x 轴上的分量。在没有波的影响时,切线方向是沿着 x 轴的
          // normalX = vec3(1.0 + normalX.x, normalX.y, normalX.z);
          // ---------------------------------------------------------
          normalX.x += Q * dir.x * dir.x * k * A * -sin(value);
          normalX.y += dir.x * k * A * cos(value);
          normalX.z += Q * dir.x * dir.y * k * A * -sin(value);

          // T=dP/dz
          // Tx=dPx/dz   --->   dx * k * a * (-sin(value))
          // Ty=dPy/dz   --->   k * a * cos(value)
          // Tz=dPz/dz   --->   dy * k * a * (-sin(value))
          normalZ.x += Q * dir.x * dir.y * k * A * -sin(value);
          normalZ.y += dir.y * k * A * cos(value);
          normalZ.z += Q * dir.y * dir.y * k * A * -sin(value);
        }


        // 将所有波的切线和副切线贡献累加到初始的切线和副切线上,得到最终的切线和副切线,然后叉积计算得到该点的法向量
        // 需要注意的是,这里对切线和副切线的x和z分量分别加了1,这是因为初始的切线和副切线是单位向量,分别指向x轴和z轴方向
        normalX = vec3(1.0 + normalX.x, normalX.y, normalX.z);
        normalZ = vec3(normalZ.x, normalZ.y, 1.0 + normalZ.z);

        vec3 finalNormal = normalize(cross(normalZ, normalX));

        GerstnerResult result;
        result.position = xyz;
        result.normal = finalNormal;

        return result;
      }

      void main() {
        float x = position.x;
        float y = position.y;
        float z = position.z;

        // -------------------------------------------------------xyz和uv-------------------------------------------------------
        // 由此段可以看出,x、y、z的分布。右手坐标系的中心为中点,向右x正,向下y正。左手坐标系,向右x正,向上y正。
        // vec3 color = vec3(0.0);
        // if(x > 0.0 && x < 30.0 && z > 0.0 && z < 50.0) {
        //   color = vec3(1.0, 1.0, 0.0);
        // }
        // vColor = color;
        // gl_Position = worldViewProjection * vec4(vec3(x, y, z), 1.0);


        // 由此段可以看出,uv的x、y的分布。y从右手坐标系的左上角开始,到左下角是 0 ~ 1。x从右手坐标系的左上角开始,到右上角是 0 ~ 1。
        // vec3 color = vec3(0.0);
        // if(uv.x > 0.0 && uv.x < 0.5 && uv.y > 0.0 && uv.y < 0.8) {
        //   color = vec3(1.0, 1.0, 0.0);
        // }
        // vColor = color;
        // gl_Position = worldViewProjection * vec4(vec3(x, y, z), 1.0);
        // -------------------------------------------------------xyz和uv-------------------------------------------------------


        // -------------------------------------------------------这个是使用fbm的noise来模拟海浪-------------------------------------------------------
        // 通过将多个不同频率和振幅的噪声层叠加在一起,生成更复杂的纹理。这种技术可以模拟许多自然现象,包括云、山脉、水面等
        // 但直接使用它可能效果不够理想,因为海浪具有更复杂的动态特性,例如波浪的传播、反射、破碎等
        // vec4 baseColor = texture(textureSampler, uv);
        // float allBlackColor = baseColor.r + baseColor.g + baseColor.b;
        // // if (allBlackColor == 0.0) { // 只有黑色的区域是河流
        // vec2 move1 = vec2(0.0);
        // move1.x = fbm(uv * 3.0);
        // move1.y = fbm(uv * 2.0);

        // vec2 move2 = vec2(0.0);
        // move2.x = fbm(uv + -0.2 * uTime + move1 + vec2(0.82, 0.32));
        // move2.y = fbm(uv + 0.5 * uTime + move1 + vec2(0.42, 0.732));

        // float fbm_value = fbm(uv + move2);

        // vec3 yColor = mix(vec3(0.0, 0.0, 0.0), vec3(1.0, 1.0, 1.0), clamp((fbm_value * fbm_value) * 4.0, 0.0, 1.0));
        // y += yColor.r * yColor.g * yColor.b * 1.2; // y 的最大值是 1.2

        // vColor = mix(vec3(0.1, 0.56, 1.0), vec3(0.48, 0.75, 1.0), clamp(y, 0.0, 1.0)); // vec3(0.1, 0.56, 1.0) 蓝色  vec3(0.48, 0.75, 1.0) 蓝白色  由低到高 
        // // }

        // gl_Position = worldViewProjection * vec4(vec3(x, y, z), 1.0);
        // -------------------------------------------------------这个是使用fbm的noise来模拟海浪-------------------------------------------------------




        // -------------------------------------------------------sin,y通过sin在上下周期运动,根本没有发生水平移动-------------------------------------------------------
        // vec3 color = vec3(0.0);
        // vColor = color;
        // float Amplitude = 0.2; // 波幅
        // float waveLength = 10.0; // 波长
        // float frequency = 2.0 * 3.14 / waveLength; // 频率
        // float speed = 50.0;
        // y = Amplitude * sin(frequency * (x - speed * uTime));
        // gl_Position = worldViewProjection * vec4(vec3(x, y, z), 1.0);
        // -------------------------------------------------------sin,y通过sin在上下周期运动,根本没有发生水平移动-------------------------------------------------------




        // -------------------------------------------------------使用 Gerstner 波-------------------------------------------------------
        // vec3 color = vec3(0.0);
        // vColor = color;

        GerstnerResult waveResult = getGerstner(x, y, z, uTime);
        vec3 xyz = waveResult.position;


        // 要把光照的这些计算放入到片元着色器中计算
        // --------------------------------------------------------------------------------------------
        // 光照计算(包括漫反射、高光反射等)通常应该在片元着色器(Fragment Shader)中完成,而不是顶点着色器(Vertex Shader)。
        // 精度问题:顶点着色器对每个顶点计算一次光照,而片元着色器对每个像素计算一次。对于复杂的表面细节(如波浪的泡沫、菲涅尔效应),顶点级别的插值会导致明显的锯齿或失真。
        // 视觉效果:光照效果(如高光、反射)需要精细的逐像素计算,顶点着色器的插值无法准确捕捉这些细节。
        // 性能权衡:虽然顶点着色器计算更快,但现代GPU的片元着色器性能足够处理复杂光照,且视觉效果提升显著。



        //------------------------------这个是通过normal计算,显示海水的颜色------------------------------
        // 变换法线到世界空间(需要世界矩阵)
        // 目的:从世界矩阵(world)中提取旋转部分,生成一个3x3的矩阵(normalMatrix),用于变换法线向量
        // 为什么需要单独提取旋转部分?
        // 法线向量是方向向量(只有方向,没有位置),因此不应受平移影响(平移不会改变方向)
        // 如果直接使用完整的世界矩阵(4x4),会错误地包含平移分量,导致法线方向错误
        // 通过取mat3(world),只保留旋转和缩放信息,忽略平移
        mat3 normalMatrix = mat3(world); // world是世界矩阵



        // 将模型空间的法线向量转换到世界空间,并确保转换后的法线仍然是单位向量
        // 目的:将模型空间的法线向量(waveResult.normal)通过normalMatrix变换到世界空间,并归一化(normalize)
        // 关键细节:
        // 变换到世界空间:光照计算通常在世界空间进行,因此需要将法线从模型空间转换到世界空间
        // 归一化:如果模型有非均匀缩放(例如x轴缩放2倍,y轴缩放1倍),直接变换法线会导致其长度不再为1。归一化确保法线方向正确,避免光照计算错误(如亮度异常)
        vec3 waveNormal = normalize(normalMatrix * waveResult.normal);


        
        // 将模型空间中的顶点位置(xyz)通过世界矩阵(world)变换到世界空间
        vec3 targetPosition = (world * vec4(xyz, 1.0)).xyz;



        // 海水基础颜色
        vec3 deepWaterColor = vec3(0.0, 0.549, 0.996); // 海水的深蓝色
        vec3 shallowWaterColor = vec3(0.3, 0.7, 1.0); // 天空的浅天蓝色



        // 根据深度混合基础颜色
        // 由于上面 Gerstner 的计算中,振幅的高度是[0, 1] * 0.2 + 0.2,然后循环4次,所以最大最小值是[-0.4 * 4 , 0.4 * 4]
        // depthFactor 需要归一化到 [0, 1],否则 mix() 超过 1.0 时会外插值导致波峰泛白
        float depthFactor = clamp((xyz.y + 1.6) / 3.2, 0.0, 1.0);
        vec3 waterColor = mix(deepWaterColor, shallowWaterColor, depthFactor);



        // 漫反射:模拟粗糙表面的均匀散射,决定物体的基础颜色和明暗
        // 高光反射:模拟光滑表面的镜面反射,增强物体的立体感和反光效果
        // 结合使用:通过叠加两者,可以创建更真实、更有层次的光照效果


        
        // 计算漫反射(Lambertian)
        float diffuse = max(0.0, dot(waveNormal, uLightDirection));
        vec3 diffuseColor = uLightColor * diffuse;


        
        // ------------------------计算高光反射(Blinn-Phong):步骤 1 ~ 4------------------------
        // 1、计算相机位置,指向 camera
        vec3 viewDirection = normalize(cameraPosition - targetPosition); 

        // 2、计算半程向量(Half Vector),即光线方向与视线方向的中间方向,用于简化高光计算
        vec3 halfVector = normalize(uLightDirection + viewDirection);

        // 3、计算高光强度
        // dot(waveNormal, halfVector):计算法线与半程向量的点积,得到余弦值(范围[-1, 1])
        // max(0.0, ...):确保结果非负(避免负值导致高光错误)
        // pow(..., 2.0):对余弦值取2次幂(高光指数),控制高光的锐利度:
        //      指数越大,高光越集中(如金属表面)
        //      指数越小,高光越分散(如粗糙表面)
        // 输出:specular是高光强度(范围[0, 1]),值越大表示高光越亮
        float specular = pow(max(0.0, dot(waveNormal, halfVector)), 64.0); // 指数越大高光越集中,避免大面积泛白

        // 4、生成最终的高光颜色
        vec3 specularColor = uLightColor * specular * 0.4;
        // ------------------------计算高光反射(Blinn-Phong):步骤 1 ~ 4------------------------


        
        // 菲涅尔效应(根据视角调整反射强度)
        float fresnel = pow(1.0 - abs(dot(viewDirection, waveNormal)), 2.0);
        fresnel = clamp(fresnel, 0.3, 0.9); // 限制反射强度范围
        vec3 reflectionColor = mix(deepWaterColor, shallowWaterColor, fresnel);


        
        // 最终颜色合成
        vec3 baseColor = mix(waterColor, reflectionColor, fresnel); // 基础水色与反射的混合
        baseColor += diffuseColor * 0.3 + specularColor; // 适当降低漫反射强度,防止叠加后整体泛白
        baseColor = clamp(baseColor, 0.0, 1.0); // 防止超出范围导致纯白
        


        vColor = baseColor;
        //------------------------------这个是通过normal计算,显示海水的颜色------------------------------



        //------------------------------这个是通过y的高度来混合颜色,显示海水的颜色------------------------------
        // vColor = mix(vec3(0.1, 0.56, 1.0), vec3(0.48, 0.75, 1.0), clamp(xyz.y, 0.0, 1.0)); // vec3(0.1, 0.56, 1.0) 蓝色  vec3(0.48, 0.75, 1.0) 蓝白色  由低到高.
        //------------------------------这个是通过y的高度来混合颜色,显示海水的颜色------------------------------

        float waterY = xyz.y + 1.6;
        gl_Position = worldViewProjection * vec4(vec3(xyz.x, waterY, xyz.z), 1.0);
        // -------------------------------------------------------使用 Gerstner 波-------------------------------------------------------
      }
    `

    Effect.ShadersStore['customShaderFragmentShader'] = `
      precision highp float;

      varying vec3 vColor;
      
      void main(void) {
        gl_FragColor = vec4(vColor, 1.0);
      }
    `

    const customShader = new ShaderMaterial(
      'customShader',
      scene, {
        vertex: 'customShader',
        fragment: 'customShader',
      }, {
        attributes: ['position', 'uv'],
        uniforms: ['worldViewProjection', 'cameraPosition', 'world', 'textureSampler', 'uLightDirection', 'uLightColor', 'uTime'],
        samplers: ['textureSampler'],
        needAlphaBlending: true,
      },
    )

    const texture = new Texture('/images/heightMap.png', scene)
    customShader.setTexture('textureSampler', texture)
    customShader.setFloat('uTime', uTime)
    
    return customShader
  }

  const createPlane = () => {
    const plane = MeshBuilder.CreateGround(
      'plane', 
      { 
        width: 150, 
        height: 150, 
        subdivisions: 150 
      },
      scene
    )
    const material = createSphereShader()
    plane.position = new Vector3(0, 1.6, 0)
   
    plane.material = material
    return material
  }


  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  const light = createLight()
  createAxis()
  createGui()
  createGround()
  createSphere()
  const material = createPlane()
  material.setVector3('uLightDirection', light.direction)
  material.setColor3('uLightColor', light.diffuse)
  runAnimate()

  scene.registerBeforeRender(function() {
    material.setFloat('uTime', uTime)
    uTime += 0.04
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

水面倒影

fps: 0
点击运行
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <canvas v-if="isRunning" id="shaderWaterReflection" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import { 
  WaterMaterial 
} from 'babylonjs-materials'
import {
  Engine,
  Scene,
  Texture,
  HemisphericLight,
  MeshBuilder,
  Color4,
  ArcRotateCamera,
  Vector3,
  Vector2,
  Color3,
  StandardMaterial,
  CubeTexture
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

 let sceneResources: any, adt: any

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

const initScene = async () => {
  const ele = document.getElementById("shaderWaterReflection") as any

  ele.addEventListener('wheel', function(event: any) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = false

  const camera = new ArcRotateCamera('camera', -Math.PI / 1.5, Math.PI / 2.2, 15, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 30
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(50, 60, -50))

  const createLight = () => {
    const light = new HemisphericLight('light', new Vector3(0, 50, 0), scene)
    return light
  }

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }

  const createSkyBox = () => {
    const skyBox = MeshBuilder.CreateBox('skyBox', { size: 512 }, scene)
    const skyBoxMaterial = new StandardMaterial('skyBox', scene)
    skyBoxMaterial.backFaceCulling = false
    skyBoxMaterial.reflectionTexture = new CubeTexture('/images/seaBox', scene)
    skyBoxMaterial.reflectionTexture.coordinatesMode = Texture.SKYBOX_MODE
    skyBoxMaterial.diffuseColor = new Color3(0, 0, 0)
    skyBoxMaterial.specularColor = new Color3(0, 0, 0)
    skyBox.material = skyBoxMaterial
    return skyBox
  }

  const createObject = () => {
    const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 8 }, scene)
    const sphereMat = new StandardMaterial('sphere')
    sphereMat.diffuseColor = new Color3(0.0, 0.8, 0.0)
    sphere.material = sphereMat
    sphere.position.y = 8

    const box = MeshBuilder.CreateBox('box', { size: 10 }, scene)
    const boxMat = new StandardMaterial('sphere')
    boxMat.diffuseColor = new Color3(0.9, 0.0, 0.9)
    box.material = boxMat
    box.position.y = 8
    box.position.z = 20

    return box
  }

  const createWater = (list: any) =>{
    const plane = MeshBuilder.CreateGround('ground', { width: 512, height: 512 }, scene)
    const water = new WaterMaterial('water', scene)
    water.backFaceCulling = true
    water.bumpTexture = new Texture('/images/waterBump.png', scene)
    water.windForce = -15
    water.waveHeight = 0.7
    water.bumpHeight = 1.5
    water.windDirection = new Vector2(1, 1)
    water.waterColor = new Color3(200 / 255, 0, 221 / 255)
    water.colorBlendFactor = 0.0
    water.addToRenderList(list[0])
    water.addToRenderList(list[1])
    plane.material = water
  }



  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createLight()
  createAxis()
  createGui()
  const skyBox = createSkyBox()
  const object = createObject()
  createWater([skyBox, object])
  runAnimate()

  scene.registerBeforeRender(function() {
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

模拟体积云

<template>
  <div>
    <div>以下是参考</div>
    <div>
      <a href="https://zhuanlan.zhihu.com/p/501039307">体积云效果的实现,游戏世界的云合雾集</a>
    </div>
    <div>
      <a href="https://zhuanlan.zhihu.com/p/622654876">体积云渲染(Volumetric Clouds),技术美术教程</a>
    </div>
    <div>
      <a href="https://zhuanlan.zhihu.com/p/503274042">小白也能看懂的Ray March体积云</a>
    </div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <canvas v-if="isRunning" id="cloud" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  HemisphericLight,
  MeshBuilder,
  Color4,
  ArcRotateCamera,
  Vector3,
  Color3,
  RawTexture3D,
  Texture,
  Effect,
  ShaderMaterial,
  StandardMaterial,
  Material,
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

const vertex = `
  precision highp float;
  
  attribute vec3 position;
  attribute vec2 uv;
  uniform mat4 worldViewProjection;
  uniform mat4 world;
  
  varying vec3 vWorld;
  varying vec2 vUV;
  
  void main() {
    // position:顶点在模型局部空间中的坐标
    // world:世界变换矩阵 (World Matrix / Model Matrix),将顶点从模型空间转换到世界空间。平移、旋转、缩放
    // worldPos:顶点变换到世界空间后的坐标
    vec4 worldPos = world * vec4(position, 1.0);
    gl_Position = worldViewProjection * vec4(position, 1.0);
    
    vWorld = worldPos.xyz;
    vUV = uv;
  }
`

const fragment = `
  precision highp float;
  precision highp sampler3D;
  
  varying vec3 vWorld;
  varying vec2 vUV;
  
  uniform sampler3D worleyTexture;
  uniform vec3 cameraPosition;
  uniform float uTime;
  uniform float uCloudDensity;
  uniform vec3 uSunDirection;
  uniform vec3 uCloudColor;
  uniform vec3 uBoxMin;
  uniform vec3 uBoxMax;
  
  // boxMin 和 boxMax 是 AABB(轴对齐包围盒,Axis-Aligned Bounding Box) 的两个对角顶点,用来定义一个与坐标轴对齐的长方体区域。
  // 				 Y
  // 				 ↑
  // 				 |
  // 				 ┌───────────────● boxMax (右上后)
  // 				/|              /|
  // 			 / |             / |
  // 			/  |            /  |
  // 		 /   |           /   |
  // 		┌────┼──────────┐    |
  // 		│    │          │    |
  // 		│    │          │    |
  // 		│    │          │    |
  // 		│    └──────────┼────┘----X
  // 		│   /           │   /
  // 		│  /            │  /
  // 		│ /             │ /
  // 		│/              │/
  // 		●───────────────┘
  // 	 /↑
  // 	Z	boxMin (左下前)
  // 
  // 		X → 右
  // 		Z → 出屏幕
  // ---------------------------------------------------------------------
  // ro:射线原点(Ray Origin)
  // boxMin/boxMax:AABB 的最小/最大顶点
  // ---------------------------------------------------------------------
  // 逐步拆解
  // 第一步:tMin, tMax(原始交点)
  // 				vec3 tMin = (boxMin - ro) * invRd;  // 到"左下前"3个平面的t值
  // 				vec3 tMax = (boxMax - ro) * invRd;  // 到"右上前"3个平面的t值
  // 		问题:不知道哪个是"进入",哪个是"离开"(取决于射线方向)
  // 第二步:t1, t2(排序后)
  // 				vec3 t1 = min(tMin, tMax);  // 每个轴的"进入"(较小的t)
  // 				vec3 t2 = max(tMin, tMax);  // 每个轴的"离开"(较大的t)
  // 		解决:统一了方向,t1一定是进入,t2一定是离开
  // 第三步:dEnter, dExit(最终区间)
  // 				float dEnter = max(t1.x, t1.y, t1.z);  // 最晚进入 = 真正进入
  // 				float dExit  = min(t2.x, t2.y, t2.z);  // 最早离开 = 真正离开
  // 		解决:三个轴的区间求交集,得到3D盒子的相交区间
  // X轴:  tMin.x=8  tMax.x=2  ──→  t1.x=2(进)  t2.x=8(离)
  // Y轴:  tMin.y=1  tMax.y=5  ──→  t1.y=1(进)  t2.y=5(离)  
  // Z轴:  tMin.z=6  tMax.z=3  ──→  t1.z=3(进)  t2.z=6(离)
  //                               ↓
  //                          dEnter = max(2,1,3) = 3
  //                          dExit  = min(8,5,6) = 5
  // 最终相交区间: [3, 5]
  // ---------------------------------------------------------------------
  // 为什么必须分三步?
  // tMin/tMax     	计算与6个平面的原始交点
  // t1/t2	          消除方向歧义(处理射线正负方向)
  // dEnter/dExit	  三维求交(三个轴的区间交集)
  // 跳过任何一步都会错:
  // 		跳过排序:不知道哪个是进/出
  // 		跳过max/min:不知道3D空间的真正进出点
  // ---------------------------------------------------------------------
  vec2 intersectBox(vec3 ro, vec3 rd, vec3 boxMin, vec3 boxMax) {
    
    // 预计算倒数,将除法转为乘法(GPU 上 * 比 / 快)
    vec3 invRd = 1.0 / rd;
    
    // 射线-包围盒相交检测的核心数学计算
    // 计算射线到 6 个包围盒平面的距离参数 d
    // ---------------------------------------
    // 射线方程回顾
    // 射线上的任意点可以表示为:P(d) = ro + rd * d;其中 d 是沿射线方向的距离参数。
    // ---------------------------------------
    // 为什么要算这个?
    // 包围盒有 6 个面,对应 3 组平行平面:
    // X轴: x = boxMin.x  和  x = boxMax.x
    // Y轴: y = boxMin.y  和  y = boxMax.y  
    // Z轴: z = boxMin.z  和  z = boxMax.z
    // 求解射线与每个平面的交点:
    // 以 X 轴的 min 平面为例:
    // ro.x + rd.x * d = boxMin.x
    // 		=> d = (boxMin.x - ro.x) / rd.x
    // 代码中正是这个计算,只是用乘法优化了除法:
    // dMin.x = (boxMin.x - ro.x) * invRd.x  // 等价于 (boxMin.x - ro.x) / rd.x
    // ---------------------------------------
    // 						boxMax
    //          ┌────────┐
    //         /│       /│
    //        / │      / │
    //       ┌──┼─────┐  │    rd
    //       │  └─────┼──┘   ↗
    //       │ /boxMin│ /   /
    //       │/       │/   ro
    //       └────────┘
    // dMin 存储射线到达 "左下前" 三个平面 的 d 值
    // dMax 存储射线到达 "右上后" 三个平面 的 d 值
    // ---------------------------------------
    // 这两行把 3D 相交问题分解为 3 个独立的 1D 相交问题,计算出射线与 6 个平面相交的"候选"距离值。
    // ---------------------------------------
    vec3 dMin = (boxMin - ro) * invRd; // 射线与 min 平面(boxMin.x, boxMin.y, boxMin.z)相交的 d 值
    vec3 dMax = (boxMax - ro) * invRd; // 射线与 max 平面(boxMax.x, boxMax.y, boxMax.z)相交的 d 值
    
    // 由于算出的 dMin 和 dMax 不一定真的对应"进入"和"离开":
    // 		rd.x > 0	dMin.x 是进入 X 轴, dMax.x 是离开
    // 		rd.x < 0	dMin.x 是离开 X 轴, dMax.x 是进入
    // 所以后续需要:
    vec3 d1 = min(dMin, dMax); // 真正的"进入"距离
    vec3 d2 = max(dMin, dMax); // 真正的"离开"距离
    
    // 这两行是 射线-包围盒相交检测的最终判定逻辑,核心思想是:
    // 		射线必须同时穿过三个轴的"进入区间",且在任意一个轴"离开"之前就完成了相交
    // ---------------------------------------
    // 含义
    // 		射线真正"进入"盒子的时间 = 最晚进入的那个轴
    // ---------------------------------------
    // 为什么用 max?
    // 		射线要同时在 X、Y、Z 三个方向都进入 slab【面板】,才算进入盒子
    // 假设:
    // 		X 轴:d=1 时进入
    // 		Y 轴:d=2 时进入
    // 		Z 轴:d=0 时进入
    // 射线在 d=2 时才真正进入 3D 盒子(此时 X 和 Z 已经在里面了,Y 刚进来)
    // 射线在 一个轴上"进入" = 只是进入了那个方向的"两片平行平面之间"
    // 射线要 三个轴都"进入" = 才真正在盒子内部
    // 类比:三个人要同时到场才能开会,会议开始时间 = 最晚到的人到达时间
    // ---------------------------------------
    float dEnter = max(max(d1.x, d1.y), d1.z);
    // 与上同理
    // 类比:三个人开会,会议结束时间 = 最早离开的人时间
    float dExit = min(min(d2.x, d2.y), d2.z);
    
    // dEnter <= dExit	区间有效	射线确实穿过盒子
    // dExit < 0	无交点	整个盒子在射线后方
    // dEnter < 0 && dExit > 0	有交点	射线起点在盒子内部
    // dEnter > dExit	无交点	射线错过盒子(三个轴区间无交集)
    return vec2(dEnter, dExit);
  }
  
  vec3 worldToTexCoord(vec3 worldPos, vec3 boxMin, vec3 boxMax) {
    return (worldPos - boxMin) / (boxMax - boxMin);
  }
  
  float hgPhase(float cosTheta, float g) {
    float g2 = g * g;
    return (1.0 - g2) / (4.0 * 3.14159 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5));
  }
  
  void main() {
    vec3 color = vec3(0.0);
    vec3 ro = cameraPosition;
    // 射线方向 = 像素世界坐标 - 相机世界坐标
    vec3 rd = normalize(vWorld - cameraPosition);
    
    // 计算一次相交区间
    vec2 intersect = intersectBox(ro, rd, uBoxMin, uBoxMax);
    float dEnter = intersect.x;
    float dExit = intersect.y;
    
    // 无效则提前退出
    if (dExit < 0.0 || dEnter > dExit) {
      gl_FragColor = vec4(0.0);
      return;
    }
    
    // 限制步进在box内
    dEnter = max(dEnter, 0.0);
    
    // 光线步进只在这个区间内进行
    float maxStep = 128.0;
    float stepSize = (dExit - dEnter) / maxStep;
    vec3 accumulatedColor = vec3(0.0); // 累积颜色
    float transmittance = 1.0; // 透射率
    
    for (float i = 0.0; i < maxStep; i+=1.0) {
      float d = dEnter + float(i) * stepSize;
      vec3 currentPos = ro + rd * d;
      
      // 现在 worldToTexCoord 已经定义,可以正常调用
      vec3 texCoord = worldToTexCoord(currentPos, uBoxMin, uBoxMax);
      vec3 animatedCoord = texCoord + vec3(uTime * 0.2, uTime * 0.1, 0.0);
      vec4 noiseSample = texture(worleyTexture, animatedCoord);
      
      float lowFreq = noiseSample.r;
      float highFreq = noiseSample.g * 0.5;
      float densityNoise = lowFreq - highFreq;
      float density = max(0.0, densityNoise - 0.3) * uCloudDensity;
      
      vec3 edgeFactor = smoothstep(0.0, 0.1, texCoord) * smoothstep(1.0, 0.9, texCoord);
      density *= edgeFactor.x * edgeFactor.y * edgeFactor.z;
      
      if (density > 0.001) {
        float lightTransmittance = exp(-density * 2.0);
        float cosTheta = dot(rd, normalize(uSunDirection));
        float phase = hgPhase(cosTheta, 0.3);
        
        vec3 scattering = uCloudColor * phase * density * stepSize * transmittance;
        accumulatedColor += scattering;
        
        float stepOpticalDepth = density * stepSize * 2.0;
        transmittance *= exp(-stepOpticalDepth);
        
        if (transmittance < 0.01) break;
      }
    }
    
    // 添加背景色(天空渐变)
    vec3 skyColor = mix(vec3(0.3, 0.5, 0.8), vec3(0.6, 0.8, 1.0), rd.y * 0.5 + 0.5);
    vec3 finalColor = skyColor * transmittance + accumulatedColor;
    
    // 增强对比度,让云更白
    finalColor = 1.0 - pow(finalColor, vec3(0.8)); // 伽马校正提亮
    
    // 计算最终 alpha(基于累积密度)
    float finalAlpha = 1.0 - transmittance;
    
    gl_FragColor = vec4(finalColor, finalAlpha);
  }
  
`

const generateWorleyNoise3D = (width: any, height: any, depth: any) => {
  const size = width * height * depth * 4

  const data = new Uint8Array(size)

  function randomF(p: any) {
    return ((Math.sin(p[0] * 12.9898 + p[1] * 78.233 + p[2] * 53.53) * 43758.5453) % 1 + 1) % 1
  }

  function randomV3(p: any) {
    return [
      randomF([p[0], p[1], p[2]]),
      randomF([p[0] + 1.0, p[1] + 2.0, p[2] + 3.0]),
      randomF([p[0] + 4.0, p[1] + 5.0, p[2] + 6.0])
    ]
  }

  const gridSize = 4

  const featurePoints = []

  for (let z = 0; z < gridSize; z++) { // z: 0, 1, 2, 3
    for (let y = 0; y < gridSize; y++) { // y: 0, 1, 2, 3
      for (let x = 0; x < gridSize; x++) { // x: 0, 1, 2, 3
        const seed = [x * 1.5, y * 1.5, z * 1.5]

        const offset = randomV3(seed)

        featurePoints.push({
          x: x + offset[0],
          y: y + offset[1],
          z: z + offset[2]
        })
      }
    }
  }

  let index = 0
  for (let z = 0; z < depth; z++) {
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {

        const u = (x / width) * gridSize
        const v = (y / height) * gridSize
        const w = (z / depth) * gridSize

        let minDist = 999999.0

        for (let fp of featurePoints) {

          let dx = Math.abs(u - fp.x)
          let dy = Math.abs(v - fp.y)
          let dz = Math.abs(w - fp.z)

          if (dx > gridSize * 0.5) {
            dx = gridSize - dx
          }
          if (dy > gridSize * 0.5) dy = gridSize - dy
          if (dz > gridSize * 0.5) dz = gridSize - dz

          const dist = Math.sqrt(dx * dx + dy * dy + dz * dz)
          if (dist < minDist) {
            minDist = dist
          }
        }

        // 归一化距离并反转(距离越近越亮/密度越高)
        // 使用 gridSize * sqrt(3)/2 作为最大可能距离(体对角线的一半)
        // const maxPossibleDist = gridSize * 0.866 // sqrt(3)/2 ≈ 0.866
        const normalizedDist = Math.min(minDist / (gridSize * 0.8), 1.0)

        // 反转:特征点附近密度高(云),远离密度低(透明)
        const density = 1.0 - normalizedDist

        // 可选:应用曲线调整对比度
        const contrastedDensity = Math.pow(density, 2.0)

        const gray = Math.floor(contrastedDensity * 255)

        data[index++] = gray
        data[index++] = gray
        data[index++] = gray
        data[index++] = 255
      }
    }
  }

  return data
}


let sceneResources: any, adt: any

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

const initScene = async () => {
  const ele = document.getElementById("cloud") as any

  ele.addEventListener('wheel', function(event: any) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene: any = new Scene(engine)

  // 需要使用右手系,因为shader的boxMin和boxMax
  scene.useRightHandedSystem = true

  const camera = new ArcRotateCamera('camera', -Math.PI / 1.5, Math.PI / 2.2, 15, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 1
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(20, 20, 20))

  const createLight = () => {
    const light = new HemisphericLight('light', new Vector3(40, 40, 40), scene)
    light.direction = new Vector3(1.0, 0.0, 1.0)
    light.diffuse = new Color3(1.0, 0.95, 0.8)
    return light
  }

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }


  const createCloud = () => {
    const textureSize = 32
    const noiseData = generateWorleyNoise3D(textureSize, textureSize, textureSize)
    const worleyTexture3D = new RawTexture3D(
      noiseData,
      textureSize,
      textureSize,
      textureSize,
      Engine.TEXTUREFORMAT_RGBA,
      scene,
      false,
      false,
      Texture.TRILINEAR_SAMPLINGMODE,
      Engine.TEXTURETYPE_UNSIGNED_BYTE
    )
    worleyTexture3D.wrapU = Texture.WRAP_ADDRESSMODE
    worleyTexture3D.wrapV = Texture.WRAP_ADDRESSMODE
    worleyTexture3D.wrapR = Texture.WRAP_ADDRESSMODE

    const box = MeshBuilder.CreateBox('box', {
      size: 5,
    }, scene)

    Effect.ShadersStore['worleyNoiseVertexShader'] = vertex
    Effect.ShadersStore['worleyNoiseFragmentShader'] = fragment
    const worleyNoiseShader = new ShaderMaterial('worleyNoise', scene, {
      vertex: 'worleyNoise',
      fragment: 'worleyNoise'
    }, {
      attributes: ['position', 'uv'],
      uniforms: ['worldViewProjection', 'world', 'worleyTexture', 'cameraPosition', 'uTime', 'uBoxMin',
        'uBoxMax', 'uCloudDensity', 'uSunDirection', 'uCloudColor'
      ],
      samplers: ['worleyTexture'],
    })

    worleyNoiseShader.transparencyMode = Material.MATERIAL_ALPHABLEND
    worleyNoiseShader.backFaceCulling = false
    worleyNoiseShader.needDepthPrePass = true

    worleyNoiseShader.setTexture('worleyTexture', worleyTexture3D)
    worleyNoiseShader.setFloat('time', 0)
    worleyNoiseShader.setFloat('uCloudDensity', 3.0)
    worleyNoiseShader.setVector3('uSunDirection', new Vector3(10, 10, 10))
    worleyNoiseShader.setColor3('uCloudColor', new Color3(1.0, 0.95, 0.9))
    worleyNoiseShader.setVector3('uBoxMin', box.getBoundingInfo().boundingBox.minimumWorld)
    worleyNoiseShader.setVector3('uBoxMax', box.getBoundingInfo().boundingBox.maximumWorld)
    worleyNoiseShader.setVector3('cameraPosition', scene.activeCamera.position)

    box.material = worleyNoiseShader


    const sphere: any = MeshBuilder.CreateSphere('sphere', {
      diameter: 2,
    }, scene)
    sphere.material = new StandardMaterial('', scene)
    sphere.material.emissiveColor = new Color3(1, 0, 0)

    //-------------------------------------------
    scene.registerBeforeRender(() => {
      const time = performance.now() * 0.001
      worleyNoiseShader.setFloat('uTime', time)
      worleyNoiseShader.setVector3('uBoxMin', box.getBoundingInfo().boundingBox.minimumWorld)
      worleyNoiseShader.setVector3('uBoxMax', box.getBoundingInfo().boundingBox.maximumWorld)
      worleyNoiseShader.setVector3('cameraPosition', scene.activeCamera.position)
    })
  }

  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createLight()
  createAxis()
  createGui()
  runAnimate()
  createCloud()

  scene.registerBeforeRender(function() {
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

<style lang='scss' scoped>

</style>

模拟体积云【使用UBO方式,而不是传统uniform方式】

fps: 0
点击运行
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <canvas v-if="isRunning" id="cloudUbo" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  HemisphericLight,
  MeshBuilder,
  Color4,
  ArcRotateCamera,
  Vector3,
  Color3,
  RawTexture3D,
  Texture,
  Effect,
  ShaderMaterial,
  StandardMaterial,
  Material,
  UniformBuffer,
  Matrix,
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

const vertex = `
  precision highp float;
  
  attribute vec3 position;
  attribute vec2 uv;

  // std140 布局对齐规则:
  // - vec4: 16字节对齐
  // - vec3: 16字节对齐(实际占用12字节,但下一个元素从16字节边界开始)
  // - float: 4字节对齐
  // - mat4: 16字节对齐,每列占16字节
  
  // UBO 声明 - 包含高频更新的矩阵数据
  layout(std140) uniform SceneData {
    mat4 worldViewProjection;
    mat4 world;
    vec3 cameraPosition;
    float uTime;
    vec3 uBoxMin;
    float uCloudDensity;
    vec3 uBoxMax;
    float _padding1;
    vec3 uSunDirection;
    float _padding2;
    vec3 uCloudColor;
    float _padding3;
  };
  
  varying vec3 vWorld;
  varying vec2 vUV;
  
  void main() {
    vec4 worldPos = world * vec4(position, 1.0);
    gl_Position = worldViewProjection * vec4(position, 1.0);
    
    vWorld = worldPos.xyz;
    vUV = uv;
  }
`

const fragment = `
  precision highp float;
  precision highp sampler3D;
  
  varying vec3 vWorld;
  varying vec2 vUV;

  // std140 布局对齐规则:
  // - vec4: 16字节对齐
  // - vec3: 16字节对齐(实际占用12字节,但下一个元素从16字节边界开始)
  // - float: 4字节对齐
  // - mat4: 16字节对齐,每列占16字节
  
  // UBO 声明 - 必须与 vertex shader 中的布局一致
  layout(std140) uniform SceneData {
    mat4 worldViewProjection;
    mat4 world;
    vec3 cameraPosition;
    float uTime;
    vec3 uBoxMin;
    float uCloudDensity;
    vec3 uBoxMax;
    float _padding1;
    vec3 uSunDirection;
    float _padding2;
    vec3 uCloudColor;
    float _padding3;
  };
  

  uniform sampler3D worleyTexture;
  
  vec2 intersectBox(vec3 ro, vec3 rd, vec3 boxMin, vec3 boxMax) {
    
    vec3 invRd = 1.0 / rd;

    vec3 dMin = (boxMin - ro) * invRd;
    vec3 dMax = (boxMax - ro) * invRd;
    
    vec3 d1 = min(dMin, dMax);
    vec3 d2 = max(dMin, dMax);
    
    float dEnter = max(max(d1.x, d1.y), d1.z);

    float dExit = min(min(d2.x, d2.y), d2.z);

    return vec2(dEnter, dExit);
  }
  
  vec3 worldToTexCoord(vec3 worldPos, vec3 boxMin, vec3 boxMax) {
    return (worldPos - boxMin) / (boxMax - boxMin);
  }
  
  float hgPhase(float cosTheta, float g) {
    float g2 = g * g;
    return (1.0 - g2) / (4.0 * 3.14159 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5));
  }
  
  void main() {
    vec3 color = vec3(0.0);
    vec3 ro = cameraPosition;
    
    vec3 rd = normalize(vWorld - cameraPosition);
    
    vec2 intersect = intersectBox(ro, rd, uBoxMin, uBoxMax);
    float dEnter = intersect.x;
    float dExit = intersect.y;
    
    if (dExit < 0.0 || dEnter > dExit) {
      gl_FragColor = vec4(0.0);
      return;
    }
    
    dEnter = max(dEnter, 0.0);
    
    float maxStep = 128.0;
    float stepSize = (dExit - dEnter) / maxStep;
    vec3 accumulatedColor = vec3(0.0);
    float transmittance = 1.0; // 透射率
    
    for (float i = 0.0; i < maxStep; i+=1.0) {
      float d = dEnter + float(i) * stepSize;
      vec3 currentPos = ro + rd * d;
      
      vec3 texCoord = worldToTexCoord(currentPos, uBoxMin, uBoxMax);
      vec3 animatedCoord = texCoord + vec3(uTime * 0.2, uTime * 0.1, 0.0);
      vec4 noiseSample = texture(worleyTexture, animatedCoord);
      
      float lowFreq = noiseSample.r;
      float highFreq = noiseSample.g * 0.5;
      float densityNoise = lowFreq - highFreq;
      float density = max(0.0, densityNoise - 0.3) * uCloudDensity;
      
      vec3 edgeFactor = smoothstep(0.0, 0.1, texCoord) * smoothstep(1.0, 0.9, texCoord);
      density *= edgeFactor.x * edgeFactor.y * edgeFactor.z;
      
      if (density > 0.001) {
        float lightTransmittance = exp(-density * 2.0);
        float cosTheta = dot(rd, normalize(uSunDirection));
        float phase = hgPhase(cosTheta, 0.3);
        
        vec3 scattering = uCloudColor * phase * density * stepSize * transmittance;
        accumulatedColor += scattering;
        
        float stepOpticalDepth = density * stepSize * 2.0;
        transmittance *= exp(-stepOpticalDepth);
        
        if (transmittance < 0.01) break;
      }
    }
    
    vec3 skyColor = mix(vec3(0.3, 0.5, 0.8), vec3(0.6, 0.8, 1.0), rd.y * 0.5 + 0.5);
    vec3 finalColor = skyColor * transmittance + accumulatedColor;
    
    finalColor = 1.0 - pow(finalColor, vec3(0.8));
    
    float finalAlpha = 1.0 - transmittance;
    
    gl_FragColor = vec4(finalColor, finalAlpha);
  }
  
`

const generateWorleyNoise3D = (width: any, height: any, depth: any) => {
  const size = width * height * depth * 4

  const data = new Uint8Array(size)

  function randomF(p: any) {
    return ((Math.sin(p[0] * 12.9898 + p[1] * 78.233 + p[2] * 53.53) * 43758.5453) % 1 + 1) % 1
  }

  function randomV3(p: any) {
    return [
      randomF([p[0], p[1], p[2]]),
      randomF([p[0] + 1.0, p[1] + 2.0, p[2] + 3.0]),
      randomF([p[0] + 4.0, p[1] + 5.0, p[2] + 6.0])
    ]
  }

  const gridSize = 4

  const featurePoints = []

  for (let z = 0; z < gridSize; z++) {
    for (let y = 0; y < gridSize; y++) {
      for (let x = 0; x < gridSize; x++) {
        const seed = [x * 1.5, y * 1.5, z * 1.5]

        const offset = randomV3(seed)

        featurePoints.push({
          x: x + offset[0],
          y: y + offset[1],
          z: z + offset[2]
        })
      }
    }
  }

  let index = 0
  for (let z = 0; z < depth; z++) {
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {

        const u = (x / width) * gridSize
        const v = (y / height) * gridSize
        const w = (z / depth) * gridSize

        let minDist = 999999.0

        for (let fp of featurePoints) {

          let dx = Math.abs(u - fp.x)
          let dy = Math.abs(v - fp.y)
          let dz = Math.abs(w - fp.z)

          if (dx > gridSize * 0.5) {
            dx = gridSize - dx
          }
          if (dy > gridSize * 0.5) dy = gridSize - dy
          if (dz > gridSize * 0.5) dz = gridSize - dz

          const dist = Math.sqrt(dx * dx + dy * dy + dz * dz)
          if (dist < minDist) {
            minDist = dist
          }
        }

        const normalizedDist = Math.min(minDist / (gridSize * 0.8), 1.0)

        const density = 1.0 - normalizedDist

        const contrastedDensity = Math.pow(density, 2.0)

        const gray = Math.floor(contrastedDensity * 255)

        data[index++] = gray
        data[index++] = gray
        data[index++] = gray
        data[index++] = 255
      }
    }
  }

  return data
}


let sceneResources: any, adt: any, cloudUniformBuffer: UniformBuffer | null = null

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

const initScene = async () => {
  const ele = document.getElementById("cloudUbo") as any

  ele.addEventListener('wheel', function(event: any) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene: any = new Scene(engine)

  // 需要使用右手系,因为shader的boxMin和boxMax
  scene.useRightHandedSystem = true

  const camera = new ArcRotateCamera('camera', -Math.PI / 1.5, Math.PI / 2.2, 15, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 1
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(20, 20, 20))

  const createLight = () => {
    const light = new HemisphericLight('light', new Vector3(40, 40, 40), scene)
    light.direction = new Vector3(1.0, 0.0, 1.0)
    light.diffuse = new Color3(1.0, 0.95, 0.8)
    return light
  }

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }


  const createCloud = () => {
    const textureSize = 32
    const noiseData = generateWorleyNoise3D(textureSize, textureSize, textureSize)
    const worleyTexture3D = new RawTexture3D(
      noiseData,
      textureSize,
      textureSize,
      textureSize,
      Engine.TEXTUREFORMAT_RGBA,
      scene,
      false,
      false,
      Texture.TRILINEAR_SAMPLINGMODE,
      Engine.TEXTURETYPE_UNSIGNED_BYTE
    )
    worleyTexture3D.wrapU = Texture.WRAP_ADDRESSMODE
    worleyTexture3D.wrapV = Texture.WRAP_ADDRESSMODE
    worleyTexture3D.wrapR = Texture.WRAP_ADDRESSMODE

    const box = MeshBuilder.CreateBox('box', {
      size: 5,
    }, scene)

    // 创建 Uniform Buffer
    // 计算大小:mat4(64) + mat4(64) + vec3(16) + float(4) + vec3(16) + float(4) + vec3(16) + float(4) + vec3(16) + float(4) + vec3(16) + float(4) = 224字节,对齐到256
    cloudUniformBuffer = new UniformBuffer(engine)

    // 定义 UBO 结构
    cloudUniformBuffer.addUniform("worldViewProjection", 16)  // mat4
    cloudUniformBuffer.addUniform("world", 16)                // mat4  
    cloudUniformBuffer.addUniform("cameraPosition", 3)        // vec3
    cloudUniformBuffer.addUniform("uTime", 1)                 // float
    cloudUniformBuffer.addUniform("uBoxMin", 3)               // vec3
    cloudUniformBuffer.addUniform("uCloudDensity", 1)         // float
    cloudUniformBuffer.addUniform("uBoxMax", 3)               // vec3
    cloudUniformBuffer.addUniform("_padding1", 1)             // float (对齐用)
    cloudUniformBuffer.addUniform("uSunDirection", 3)         // vec3
    cloudUniformBuffer.addUniform("_padding2", 1)             // float (对齐用)
    cloudUniformBuffer.addUniform("uCloudColor", 3)           // vec3
    cloudUniformBuffer.addUniform("_padding3", 1)             // float (对齐用)

    cloudUniformBuffer.create()

    Effect.ShadersStore['worleyNoiseVertexShader'] = vertex
    Effect.ShadersStore['worleyNoiseFragmentShader'] = fragment

    const worleyNoiseShader = new ShaderMaterial('worleyNoise', scene, {
      vertex: 'worleyNoise',
      fragment: 'worleyNoise'
    }, {
      attributes: ['position', 'uv'],
      uniforms: ['SceneData'],  // 声明使用 UBO
      uniformBuffers: ['SceneData'],  // 关键:声明使用的 UBO 名称
      samplers: ['worleyTexture'],
    })

    // 绑定 UBO 到 shader
    worleyNoiseShader.setUniformBuffer("SceneData", cloudUniformBuffer)
    worleyNoiseShader.setTexture('worleyTexture', worleyTexture3D)

    worleyNoiseShader.transparencyMode = Material.MATERIAL_ALPHABLEND
    worleyNoiseShader.backFaceCulling = false
    worleyNoiseShader.needDepthPrePass = true

    box.material = worleyNoiseShader


    const sphere: any = MeshBuilder.CreateSphere('sphere', {
      diameter: 2,
    }, scene)
    sphere.material = new StandardMaterial('', scene)
    sphere.material.emissiveColor = new Color3(1, 0, 0)


    // 初始化 UBO 静态数据
    const initialSunDirection = new Vector3(10, 10, 10).normalize()
    const initialCloudColor = new Color3(1.0, 0.95, 0.9)
    
    cloudUniformBuffer.updateMatrix("worldViewProjection", Matrix.Identity())
    cloudUniformBuffer.updateMatrix("world", box.getWorldMatrix())
    cloudUniformBuffer.updateFloat3("cameraPosition", camera.position.x, camera.position.y, camera.position.z)
    cloudUniformBuffer.updateFloat("uTime", 0)
    
    const bbox = box.getBoundingInfo().boundingBox
    cloudUniformBuffer.updateFloat3("uBoxMin", bbox.minimumWorld.x, bbox.minimumWorld.y, bbox.minimumWorld.z)
    cloudUniformBuffer.updateFloat("uCloudDensity", 3.0)
    cloudUniformBuffer.updateFloat3("uBoxMax", bbox.maximumWorld.x, bbox.maximumWorld.y, bbox.maximumWorld.z)
    cloudUniformBuffer.updateFloat("_padding1", 0)
    cloudUniformBuffer.updateFloat3("uSunDirection", initialSunDirection.x, initialSunDirection.y, initialSunDirection.z)
    cloudUniformBuffer.updateFloat("_padding2", 0)
    cloudUniformBuffer.updateFloat3("uCloudColor", initialCloudColor.r, initialCloudColor.g, initialCloudColor.b)
    cloudUniformBuffer.updateFloat("_padding3", 0)
    
    cloudUniformBuffer.update()

    //-------------------------------------------
    scene.registerBeforeRender(() => {
      const time = performance.now() * 0.001
     
      // 获取世界矩阵和视图投影矩阵
      const worldMatrix = box.getWorldMatrix()
      const viewProjection = scene.getTransformMatrix()
      const wvp = worldMatrix.multiply(viewProjection)
      
      // 更新 UBO 数据
      cloudUniformBuffer!.updateMatrix("worldViewProjection", wvp)
      cloudUniformBuffer!.updateMatrix("world", worldMatrix)
      cloudUniformBuffer!.updateFloat3("cameraPosition", scene.activeCamera.position.x, scene.activeCamera.position.y, scene.activeCamera.position.z)
      cloudUniformBuffer!.updateFloat("uTime", time)
      
      const currentBbox = box.getBoundingInfo().boundingBox
      cloudUniformBuffer!.updateFloat3("uBoxMin", currentBbox.minimumWorld.x, currentBbox.minimumWorld.y, currentBbox.minimumWorld.z)
      cloudUniformBuffer!.updateFloat3("uBoxMax", currentBbox.maximumWorld.x, currentBbox.maximumWorld.y, currentBbox.maximumWorld.z)
      
      // 一次性提交所有更新到 GPU
      cloudUniformBuffer!.update()
    })
  }

  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createLight()
  createAxis()
  createGui()
  runAnimate()
  createCloud()

  scene.registerBeforeRender(function() {
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

<style lang='scss' scoped>

</style>

球体的坑

fps: 0
点击运行
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <canvas v-if="isRunning" id="spherePit" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  HemisphericLight,
  MeshBuilder,
  Color4,
  ArcRotateCamera,
  Vector3,
  Color3,
  Effect,
  ShaderMaterial,
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

let sceneResources: any, adt: any

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

const initScene = async () => {
  const ele = document.getElementById("spherePit") as any

  ele.addEventListener('wheel', function(event: any) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = true

  const camera = new ArcRotateCamera('camera', -Math.PI / 1.5, Math.PI / 2.2, 15, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 1
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(2, 2, 2))

  const createLight = () => {
    const light = new HemisphericLight('light', new Vector3(40, 40, 40), scene)
    light.direction = new Vector3(1.0, 0.0, 1.0)
    light.diffuse = new Color3(1.0, 0.95, 0.8)
    return light
  }

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }


  // 顶点着色器
  Effect.ShadersStore['customsVertexShader'] = `
    precision highp float;
    attribute vec3 position; // 顶点局部坐标
    attribute vec3 normal; // 顶点法线
    attribute vec2 uv; // 纹理坐标
    uniform mat4 world; // 世界变换矩阵
    uniform mat4 worldViewProjection; // MVP矩阵
    uniform vec3 crater1Pos; // 陨石坑1中心方向(世界空间)
    uniform float crater1Radius; // 陨石坑1角度半径(弧度)
    uniform float crater1Depth; // 陨石坑1深度
    uniform vec3 crater2Pos;
    uniform float crater2Radius;
    uniform float crater2Depth;
    
    varying vec3 vWorldPos; // 传递到片段着色器的世界坐标
    varying vec3 vWorldNormal; // 世界法线
    varying vec2 vUV; // UV坐标
    varying float vCraterDepth; // 传递陨石坑深度(用于着色)
    
    void main() {
      // 将顶点变换到世界空间。法线变换时 w=0 避免平移影响
      vec4 worldPos4 = world * vec4(position, 1.0); // 将局部坐标乘以世界矩阵得到4D世界坐标,vec4(position, 1.0)中的1.0表示这是一个点(不是向量),需要接受平移变换
      vec3 worldPos = worldPos4.xyz; // 提取xyz分量得到3D世界坐标
      vec3 worldNormal = normalize((world * vec4(normal, 0.0)).xyz); // 将法线变换到世界空间并归一化,vec4(normal, 0.0)中的0.0表示这是向量(法线),只接受旋转不接受平移
      
      // 计算角距离
      vec3 normPos = normalize(worldPos); // 球面方向向量(单位长度)。将世界坐标归一化为单位向量,球面上任意点的方向向量长度必须为1,这是球面几何计算的基础
      // dot(normPos, crater1Pos)
      // 计算两个单位向量的点积
      // 	normPos 是当前顶点的方向向量(从球心指向顶点,已归一化)
      // 	crater1Pos 是陨石坑中心的方向向量(从球心指向坑中心,已归一化)
      // 原理:两个单位向量的点积等于它们夹角的余弦值:dot(a,b) = |a||b|cosθ = cosθ
      // 结果:得到一个 [-1.0, 1.0] 范围内的值,表示 cos(θ)
      // 为什么必须这样做:虽然理论上两个归一化向量的点积必定在 [-1,1] 之间,但浮点数计算存在精度误差,结果可能略微超出这个范围(如 1.0000001 或 -1.0000001)
      // ********************************************
      // 通过angle来判断,angle越是小,就接近坑的中心,坑就越深
      float dot1 = clamp(dot(normPos, crater1Pos), -1.0, 1.0); // 计算顶点方向与陨石坑1方向的点积并限制范围,dot(a,b)=|a||b|cosθ,归一化后点积=cosθ。clamp防止浮点误差超出[-1,1]导致acos返回NaN
      float angle1 = acos(dot1); // 将cos(θ1)转换为角度θ1,acos是反余弦函数,得到弧度值。需要角度值才能与半径比较
      
      float dot2 = clamp(dot(normPos, crater2Pos), -1.0, 1.0);
      float angle2 = acos(dot2);
      
      // 创建尖锐的陨石坑形状
      float crater1 = 1.0 - smoothstep(0.0, crater1Radius, angle1); // 计算第一个陨石坑的影响因子,smoothstep(0, r, θ)在θ∈[0,r]时从0平滑过渡到1。1.0 -反转:中心为1(最深),边缘为0(无影响)
      crater1 = pow(crater1, 1.5); // 更陡峭的边缘【对影响因子做幂运算使其更陡峭】,pow(x, 1.5)是非线性函数,x∈[0,1]时结果衰减更快,模拟陨石坑的碗状结构
      crater1 = crater1 * crater1Depth; // 乘以深度系数,将归一化的[0,1]影响因子转换为实际深度值
      
      float crater2 = 1.0 - smoothstep(0.0, crater2Radius, angle2);
      crater2 = pow(crater2, 1.5);
      crater2 = crater2 * crater2Depth;
      
      float totalDepth = max(crater1, crater2); // 取两个坑的最大深度,两个坑可能重叠,max()确保只显示更深的那个,避免深度叠加导致奇怪形状
      vec3 displaced = worldPos - worldNormal * totalDepth; // 沿法线方向向内位移顶点,worldNormal * totalDepth是位移向量,减法实现凹陷。必须用世界法线才能在正确方向位移
      
      gl_Position = worldViewProjection * vec4(displaced, 1.0); // 计算最终裁剪空间坐标
      
      vWorldPos = displaced; // 将位移后的世界坐标传递给片段着色器,片段着色器需要基于最终位置计算视线方向
      vWorldNormal = worldNormal; // 将世界法线传递给片段着色器,片段着色器需要法线计算光照
      vUV = uv; // 将UV坐标传递给片段着色器,用于生成表面噪声
      vCraterDepth = totalDepth; // 将总深度传递给片段着色器,片段着色器需要知道该像素是否在坑内及深度,用于颜色变暗
    }
  `

  // 片段着色器 - 增强光照和颜色对比
  Effect.ShadersStore['customsFragmentShader'] = `
    precision highp float;
    varying vec3 vWorldPos;
    varying vec3 vWorldNormal;
    varying vec2 vUV;
    varying float vCraterDepth;
    
    void main() {
      // 基础颜色 - 坑内更暗
      vec3 baseColor = vec3(0.65, 0.63, 0.58); // 定义基础颜色为月灰色,灰色调模拟月球表面岩石
      baseColor *= (1.0 - vCraterDepth * 0.8); // 根据深度变暗颜色,vCraterDepth在平面处为0,坑中心最大。乘以(1-深度)实现坑内更暗的视觉效果

      vec3 viewDir = normalize(-vWorldPos); // 计算视线方向(从像素指向相机),高光和边缘光都需要视线方向。相机在原点,所以-vWorldPos是视线向量
      
      // 主光源(方向光)
      vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0)); // 定义主光源方向并归一化,方向光模拟太阳光,归一化确保长度1用于点积计算
      float diff = max(dot(vWorldNormal, lightDir), 0.0) * 0.7 + 0.3; // 计算Lambert漫反射,dot(法线, 光线)得cosθ,即光照强度。max(...,0)避免背面负值。*0.7+0.3添加30%环境光使暗部不完全黑
      
      // 镜面高光【这里不需要镜面高光,只是写出来,作为知识储备】
      // vec3 reflectDir = reflect(-lightDir, vWorldNormal); // 计算光线反射方向,reflect函数需要入射方向(从光源指向表面),所以用-lightDir
      // float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); // 计算Phong镜面高光强度,dot(视线,反射)得余弦值,pow(...,32)使高光更锐利集中。指数越大光斑越小
      // vec3 specular = vec3(0.4, 0.4, 0.4) * spec; // 计算最终高光颜色,灰色高光模拟岩石表面的微弱反光
      vec3 specular = vec3(0.0, 0.0, 0.0);

      // 边缘光
      float rim = 1.0 - max(dot(viewDir, vWorldNormal), 0.0); // 计算边缘光强度,视线与法线垂直时dot=0,rim=1(边缘最亮),实现Fresnel效应
      rim = pow(rim, 3.0); // 对边缘光做幂运算,pow使边缘光更集中在极边缘,避免大面积过亮
      vec3 rimColor = vec3(0.2, 0.2, 0.3) * rim; // 计算最终边缘光颜色,蓝灰色边缘光增强立体感
      
      // 表面噪声
      float noise = fract(sin(dot(vUV, vec2(12.9898, 78.233))) * 43758.5453); // 生成伪随机噪声,dot(vUV, vec2(...))将2D UV映射到1D,sin*...*fract是经典的Hash函数,无需纹理即可生成噪声
      baseColor += (noise - 0.5) * 0.1; // 将噪声添加到基础色(±5%扰动),noise-0.5使扰动对称,*0.1控制强度。模拟岩石表面纹理
      
      vec3 color = baseColor * diff + specular + rimColor; // 合并所有颜色贡献,基础色×漫反射=主要明暗,+高光=反光,+边缘光=轮廓增强
      gl_FragColor = vec4(color, 1.0); 
    }
  `


  const createSphere = () => {
    const sphere = MeshBuilder.CreateSphere('sphere', {
      diameter: 2,
      segments: 128
    }, scene)

    const craterMaterial = new ShaderMaterial('craterMat', scene, {
      vertex: 'customs',
      fragment: 'customs',
    }, {
      attributes: ['position', 'normal', 'uv'],
      uniforms: ['world', 'worldViewProjection']
    })

    // 陨石坑参数
    craterMaterial.setVector3('crater1Pos', new Vector3(0.3, 0.4, 0.5).normalize())
    craterMaterial.setFloat('crater1Radius', 0.5)
    craterMaterial.setFloat('crater1Depth', 0.2)

    craterMaterial.setVector3('crater2Pos', new Vector3(-0.4, -0.3, 0.4).normalize())
    craterMaterial.setFloat('crater2Radius', 0.4)
    craterMaterial.setFloat('crater2Depth', 0.25)

    sphere.material = craterMaterial
  }




  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createLight()
  createAxis()
  createGui()
  runAnimate()
  createSphere()

  scene.registerBeforeRender(function() {
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

<style lang='scss' scoped>

</style>

菲利普频谱

fps: 0
点击运行
<template>
  <div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <canvas v-if="isRunning" id="phillips" class="stage"></canvas>
  </div>
</template>
  
<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  HemisphericLight,
  MeshBuilder,
  Effect,
  ShaderMaterial,
  Color4,
  ArcRotateCamera,
  Vector3,
  Vector2,
  Color3,
  StandardMaterial
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

let sceneResources: any, adt: any
let uTime = 0.0

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

const initScene = async () => {
  const ele = document.getElementById("phillips") as any

  ele.addEventListener('wheel', function(event: any) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = false

  const camera = new ArcRotateCamera('camera', -Math.PI / 1.5, Math.PI / 2.2, 15, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 1
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(160, 160, -160))

  const createLight = () => {
    const light = new HemisphericLight('light', new Vector3(40, 40, 40), scene)
    light.direction = new Vector3(1.0, 0.0, 1.0)
    light.diffuse = new Color3(1.0, 0.95, 0.8)
    return light
  }

  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }

  const createSphere = () => {
    const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 10 }, scene)
    const sphereMat = new StandardMaterial('sphere')
    sphereMat.diffuseColor = new Color3(1.0, 0.6, 0.2)
    sphere.material = sphereMat
    sphere.position.x = 70
    sphere.position.y = 70
    sphere.position.z = 70
  }

  const createSphereShader = () => {
    Effect.ShadersStore['customShaderVertexShader'] = `
      precision highp float;

      const float PI = 3.14159265358979323846;
      const float TWO_PI = 2.0 * PI;
      const float G = 9.8;
      const float N = 64.0;

      attribute vec3 position;
      attribute vec2 uv;

      uniform mat4 worldViewProjection; // 投影

      uniform float uTime;
      uniform vec2 uResolution;


      varying vec3 vColor;
      varying vec2 vResolution;

      // 快速的伪随机
      float randValueFast(vec2 uv, float magic) {
        vec2 random2 = (1.0 / 4320.0) * uv + vec2(0.25, 0.0);
        float random = fract(dot(random2 * random2, vec2(magic)));
        random = fract(random * random * (2.0 * magic));

        return random; 
      }


      // 复数乘法
      vec2 complexMultiply(vec2 a, vec2 b){
        return vec2(
          a.x * b.x - a.y * b.y,  // 实部
          a.x * b.y + a.y * b.x   // 虚部
        );
      }


      // 色散关系函数
      float dispersion(vec2 k){
        return sqrt(G * length(k));
      }


      //计算两个相互独立的高斯随机数
      vec2 gauss(vec2 uv) {
        float u1 = randValueFast(uv, 1753.0);
        float u2 = randValueFast(uv, 3571.0);
        if(u1 < 1e-6) {
          u1 = 1e-6;
        }

        float g1 = sqrt(-2.0 * log(u1)) * cos(TWO_PI * u2);
        float g2 = sqrt(-2.0 * log(u1)) * sin(TWO_PI * u2);

        return vec2(g1, g2);
      }


      // 菲利普计算,输入波数 K
      float phillips(vec2 K) {
        // vec2 K = vec2(TWO_PI * n.x / 100.0, TWO_PI * n.y / 100.0); // 计算波数向量 K
        
        vec2 W = vec2(1.0, -1.0); // 定义一个方向向量(风)

        float V = 10.0; // 定义一个速度变量 V

        float A = 10.0; // 定义一个振幅变量 A。

        float L = V * V / G; // 计算波长 L。
        float L2 = L * L;
        
        float kLen = length(K); // 计算波数向量 K 的模长
        kLen = max(0.0001, kLen);
        float kLen2 = kLen * kLen;
        float kLen4 = kLen2 * kLen2;

        vec2 K_norm = normalize(K);
        vec2 W_norm = normalize(W);
        float dot_KW = dot(K_norm, W_norm);

        // 基础 Phillips 谱
        float phi = A * exp(-1.0 / (kLen2 * L2)) / kLen4;

        // dot_KW * dot_KW(称为风向拓展函数)
        // 风向对齐项(加强沿风向的波能)
        phi *= dot_KW * dot_KW;

        // 这一行的思想,必须要记住★★★★★★★★★★★★★★★★★★★★★★★★★★
        // 只要与方向有关的,一律优先思考dot
        // 这个是判断dot的方向是否与风的方向一致
        // 没有这一行,则两个椭圆一样大
        // 可以通过dot来---1:uv的增大缩小、2:光照强度、3:相似度 ......
        if(dot_KW > 0.0) {
          phi *= V;
        }
        // 这一行的思想,必须要记住★★★★★★★★★★★★★★★★★★★★★★★★★★

        // 衰减因子
        float l = 0.001 * L;
        float kSqr = dot(K, K);
        phi *= exp(l * l * -kSqr);

        return phi;
      }

      void main() {
        float x = position.x;
        float y = position.y;
        float z = position.z;

        vec3 color = vec3(0.0);

        // ★★★★★★★★★★★★★★ 必须要注意uResolution,和纯shader的用法不一样 ★★★★★★★★★★★★★★

        // 如果没有 pixelCoord 则 color.xy = vec2(gaussValue2); 的高斯图会有谐波出现,而不是噪点图
        vec2 pixelCoord = uv * uResolution; // 将归一化UV转换为像素坐标
        vec2 gaussValue1 = gauss(pixelCoord + vec2(3.0, 5.0)); // 用于 h0k
        vec2 gaussValue2 = gauss(pixelCoord + vec2(7.0, 11.0)); // 用于 h0kConj

        
        float nx = (uv.x - 0.5) * uResolution.x; // 还原到像素尺度
        float ny = (uv.y - 0.5) * uResolution.y; // 还原到像素尺度
        

        // 计算波数 K
        vec2 K = vec2(TWO_PI * nx / N, TWO_PI * ny / N);
        // 计算 h0(k) 和 h0*(-k)
        // 这个使用风向拓展函数
        vec2 h0k = gaussValue1 * sqrt(phillips(K) * 0.5);  // 初始频谱(由菲利普频谱与高斯随机数生成)
        vec2 h0kConj = gaussValue2 * sqrt(phillips(-K) * 0.5); // 共轭复数
        h0kConj.y *= -1.0; // 为什么 *-1.0 , ax + i·b,其共轭复数是 ax - i·b

        float omega = dispersion(K) * uTime;
        float c = cos(omega);
        float s = sin(omega);
        vec2 h1 = complexMultiply(h0k, vec2(c, s));
        vec2 h2 = complexMultiply(h0kConj, vec2(c, -s));

        vec2 H_Tilde = h1 + h2; // H_Tilde 波浪H是数学公式中的写法



        // 计算 KxHTilde 和 KzHTilde 是为了获取到法线

        vec2 maxK = K / max(0.001, length(K));
        vec2 KxHTilde = complexMultiply(vec2(0, -maxK.x), H_Tilde);
        vec2 KzHTilde = complexMultiply(vec2(0, -maxK.y), H_Tilde);


        // 默认法线向上,即没有波浪时候
        vec3 normal = vec3(0.0, 1.0, 0.0);
        if (length(K) > 0.001) {  // 跳过直流分量

          // 这里取实数部分,不用虚数
          // 在数学和物理的多个领域(如波动理论、电磁学、量子力学等)中,当处理 dh/dx 和 dh/dz 等导数时,若函数 h 是复数形式,通常只取其实数部分
          vec2 dHdx = complexMultiply(vec2(0, K.x), H_Tilde);
          vec2 dHdz = complexMultiply(vec2(0, K.y), H_Tilde);

          // 为什么是负数?
          // 梯度是“上升最快的方向”
          // 法线是“垂直于表面”的方向
          // 法线在水平面上的投影,正好是梯度的反方向
          // 直观理解:
          // 因为法线必须垂直于斜坡表面
          // 如果斜坡向东倾斜(东高西低),那么法线必须向西倾斜(西高东低)才能垂直于斜坡
          // 因此,法线在水平面上的投影指向西方(即梯度的反方向)
          normal = normalize(vec3(-dHdx.x, 1.0, -dHdz.x));
        }



        color.xy = vec2(H_Tilde);
        vColor = color;

        gl_Position = worldViewProjection * vec4(vec3(x, y, z), 1.0);
      }
    `

    Effect.ShadersStore['customShaderFragmentShader'] = `
      precision highp float;

      varying vec3 vColor;

      void main(void) {
        gl_FragColor = vec4(vColor, 1.0);
      }
    `

    const customShader = new ShaderMaterial(
      'customShader',
      scene, {
        vertex: 'customShader',
        fragment: 'customShader',
      }, {
        attributes: ['position', 'uv'],
        uniforms: ['worldViewProjection', 'uResolution', 'uTime'],
        needAlphaBlending: true,
      },
    )


    customShader.setFloat('uDown', uTime)
    
    return customShader
  }

  const createPlane = () => {
    const plane = MeshBuilder.CreateGround(
      'plane', 
      { 
        width: 256, 
        height: 256, 
        subdivisions: 256 
      },
      scene
    )
    const material = createSphereShader()
    
    plane.material = material
    return material
  }


  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createLight()
  createAxis()
  createGui()
  createSphere()
  const material = createPlane()
  material.setVector2('uResolution', new Vector2(256, 256))
  runAnimate()

  scene.registerBeforeRender(function() {
    material.setFloat('uTime', uTime)
    uTime += 0.02
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
})

onUnmounted(() => {
  destroy()
})
</script>

proceduralTexture(继承renderTargetTexture)(注意二者区别)

proceduralTexture1和proceduralTexture2的区别:
如果第一个proceduralTexture传入了rawTextureY这些数据,想通过uTime改变texture(uSampler, vUV)是不起效果的,还会报错。
只有等创建了一份ProceduralTexture后,才能在第二份的ProceduralTexture传入uTime改变texture(uSampler, vUV)。
fps: 0
点击运行
<template>
  <div>
    <div>proceduralTexture1和proceduralTexture2的区别:</div>
    <div>如果第一个proceduralTexture传入了rawTextureY这些数据,想通过uTime改变texture(uSampler, vUV)是不起效果的,还会报错。</div>
    <div>只有等创建了一份ProceduralTexture后,才能在第二份的ProceduralTexture传入uTime改变texture(uSampler, vUV)。</div>
    <div class="flex space-between">
      <div>fps: {{ fps }}</div>
      <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    </div>
    <canvas v-if="isRunning" id="proceduralTexture1" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
  Engine,
  Scene,
  HemisphericLight,
  MeshBuilder,
  Effect,
  ShaderMaterial,
  ArcRotateCamera,
  Vector3,
  Color3,
  Color4,
  ProceduralTexture,
} from 'babylonjs'
import {
  AdvancedDynamicTexture,
  StackPanel,
  Control,
  TextBlock,
} from 'babylonjs-gui'

 let sceneResources: any, adt: any

const fps = ref(0)
const isRunning = ref(false)

const onTrigger = async () => {
  if (!isRunning.value) {
    isRunning.value = true
    await nextTick()
    sceneResources = await initScene()
  } else {
    isRunning.value = false
    destroy()
  }
}

let uTime = 0.2

const initScene = async () => {
  const ele = document.getElementById("proceduralTexture1") as any

  ele.addEventListener('wheel', function(event: any) {
    // 根据需要处理滚动
    // 例如,可以修改相机的半径或角度
    event.preventDefault() // 阻止默认滚动行为
  })

  const engine: any = new Engine(ele, true, {
    preserveDrawingBuffer: true,
    stencil: true,
    disableWebGL2Support: false
  })

  const scene = new Scene(engine)
  scene.useRightHandedSystem = false

  const camera = new ArcRotateCamera('camera', -Math.PI / 1.5, Math.PI / 2.2, 15, new Vector3(0, 0, 0), scene)
  camera.upperBetaLimit = Math.PI / 2.2
  camera.wheelPrecision = 1
  camera.panningSensibility = 10
  camera.attachControl(ele, true)
  camera.setPosition(new Vector3(0, 560, -560))

  const createLight = () => {
    const light = new HemisphericLight('light', new Vector3(40, 40, 40), scene)
    light.direction = new Vector3(1.0, 0.0, 1.0)
    light.diffuse = new Color3(1.0, 0.95, 0.8)
    return light
  }


  const createAxis = () => {
    const axisX = MeshBuilder.CreateLines(
      'axisX', {
        colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
      },
      scene
    )

    const axisY = MeshBuilder.CreateLines(
      'axisY', {
        colors: [new Color4(0, 1, 0, 1),  new Color4(0, 1, 0, 1)  ],
        points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
      },
      scene
    )

    const axisZ = MeshBuilder.CreateLines(
      'axisZ', {
        colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
        points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
      },
      scene
    )

    return [axisX, axisY, axisZ]
  }

  const createGui = async () => {
    adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')

    const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
    xBox.position = new Vector3(80, 0, 0)
    const xPanel = new StackPanel()
    xPanel.width = '20px'
    xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const x = new TextBlock()
    x.text = 'X'
    x.height = '30px'
    x.color = 'red'
    adt.addControl(xPanel)
    xPanel.addControl(x)
    xPanel.linkWithMesh(xBox)

    const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
    yBox.position = new Vector3(0, 80, 0)
    const yPanel = new StackPanel()
    yPanel.width = '20px'
    yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const y = new TextBlock()
    y.text = 'Y'
    y.height = '30px'
    y.color = 'green'
    adt.addControl(yPanel)
    yPanel.addControl(y)
    yPanel.linkWithMesh(yBox)

    const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
    zBox.position = new Vector3(0, 0, 80)
    const zPanel = new StackPanel()
    zPanel.width = '20px'
    zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
    zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
    const z = new TextBlock()
    z.text = 'Z'
    z.height = '30px'
    z.color = 'blue'
    adt.addControl(zPanel)
    zPanel.addControl(z)
    zPanel.linkWithMesh(zBox)
  }


  Effect.ShadersStore['firstShaderVertexShader'] = `
    precision highp float;
    
    attribute vec3 position;
    attribute vec2 uv;

    uniform mat4 worldViewProjection;

    varying vec2 vUV;

    void main() {
      vUV = uv;
      gl_Position = worldViewProjection * vec4(position, 1.0);
    }
  `

  Effect.ShadersStore['firstShaderFragmentShader'] = `
    precision highp float;

    varying vec2 vUV;
    uniform float uTime;

    void main() {
      vec3 c = vec3(0.0);
      if(vUV.x < 0.5) c.r = cos(uTime);
      gl_FragColor = vec4(c, 1.0);
    }
  `

  Effect.ShadersStore['secondShaderVertexShader'] = `
    precision highp float;
    
    attribute vec3 position;
    uniform mat4 worldViewProjection;
    
    attribute vec2 uv;

    varying vec2 vUV;

    void main() {
      vUV = uv;
      gl_Position = worldViewProjection * vec4(position, 1.0);
    }
  `

  Effect.ShadersStore['secondShaderFragmentShader'] = `
    precision highp float;

    uniform sampler2D uSampler;

    varying vec2 vUV;
    uniform float uTime;

    void main() {

      vec4 baseColor = texture(uSampler, vUV);

      if(vUV.y > 0.5) baseColor.g = 0.5;
      baseColor.b += sin(uTime);

      gl_FragColor = vec4(baseColor);
    }
  `

  Effect.ShadersStore['thirdShaderVertexShader'] = `
    precision highp float;
    
    attribute vec3 position;
    uniform mat4 worldViewProjection;
    
    attribute vec2 uv;

    varying vec2 vUV;

    void main(void) {
      vUV = uv;
      gl_Position = worldViewProjection * vec4(position, 1.0);
    }
  `

  Effect.ShadersStore['thirdShaderFragmentShader'] = `
    precision highp float;

    uniform sampler2D uSampler;

    varying vec2 vUV;

    void main(void) {

      vec4 baseColor = texture(uSampler, vUV);

      if(vUV.y < 0.5) baseColor.b = 0.3;

      gl_FragColor = vec4(baseColor);
    }
  `

  const createProceduralTexture = () => {
    const finalShader = new ShaderMaterial(
      'thirdShader',
      scene, {
        vertex: 'thirdShader',
        fragment: 'thirdShader',
      }, {
        attributes: ['position', 'uv'],
        uniforms: ['worldViewProjection', 'uSampler'],
        samplers: ['uSampler'],
        needAlphaBlending: true,
      },
    )

    const procTex1 = new ProceduralTexture('first', 256, 'firstShader', scene)
    const procTex2 = new ProceduralTexture('second', 256, 'secondShader', scene)
    
    const finalPlane = MeshBuilder.CreateGround('plane', { width: 256, height: 256, subdivisions: 256 },  scene)
    finalPlane.material = finalShader

    procTex2.setTexture('uSampler', procTex1)
    finalShader.setTexture('uSampler', procTex2)

    return { procTex1, procTex2 }
  }

  const runAnimate = () => {
    engine.runRenderLoop(function() {
      if (scene && scene.activeCamera) {
        scene.render()

        fps.value = engine.getFps().toFixed(2)
      }
    })
  }

  createLight()
  createAxis()
  createGui()
  runAnimate()


  const { procTex1, procTex2  } = createProceduralTexture()
  scene.registerBeforeRender(() => {
    procTex1.setFloat('uTime', uTime)
    procTex2.setFloat('uTime', uTime)
    uTime += 0.02
  })

  return {
    scene,
    engine, 
  }
}

const destroy = () => {
  if (sceneResources) {
    sceneResources.engine.stopRenderLoop() 
    sceneResources.engine.dispose()
    sceneResources.scene.dispose()
    sceneResources = null
  }
  if (adt) {
    adt.dispose()
    adt = null
  }
}

onMounted(async() => {
  await nextTick()
  // isRunning.value = true
  // await nextTick()
  // sceneResources = await initScene()
})

onUnmounted(() => {
  destroy()
})
</script>