Appearance
模拟大海(学习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>