最新消息: USBMI致力于为网友们分享Windows、安卓、IOS等主流手机系统相关的资讯以及评测、同时提供相关教程、应用、软件下载等服务。

webgl

互联网 admin 9浏览 0评论

webgl

webgl_gpgpu_birds 是一个 three.js 的官方样例,这个例子模拟了鸟群的运动,是一个群组动画,并且动画的帧率也很高;鸟群的运动很自然,非常值得研究。类似的群组动画还有鱼群,boid是‘类鸟群’的英文

大概两年前,第一次看这个例子时,很枯燥,看不懂,有很多地方不知道是什么意思。第一次看这个例子时,才知道原来 纹理 texture 可以这样来使用,这个例子可以作为一个通用的并行计算框架的例子来看待。

这个例子的鸟群中一共有 32 x 32 共 1024 只鸟。鸟群中的每只鸟的位置是一个三维坐标,保存在一张 32 x 32 像素的纹理图片缓存中,初始化时,fillPositionTexture函数负责为 每只鸟 赋予一个 [-400, 400] 闭区间内的一个随机值。鸟的位置的x y z 分量都在 -400 到 400内取随机值。

鸟群中的每只鸟的速度是一个三维向量,保存在另外一张 32 x 32 像素的纹理图片缓存中,初始化时,fillVelocityTexture函数负责为 每只鸟 的速度的x y z分向量都赋予一个 [-5, 5] 闭区间内的一个随机值。每只鸟的速率和方向都是不同的。

例子中的 GPUComputationRenderer 负责在每一帧渲染前,都去以一定的规律 或 计算模式去更新鸟群的位置纹理 和 速度纹理。这两个纹理的分辨率都是 32 x 32 像素,对于渲染器来说,分辨率很小。渲染器更新的纹理的分辨率一般都是屏幕分辨率,1024 * 768 等;所以,更新这两张贴图对于渲染器来说很轻量,写这两张纹理对应的片元着色器代码时,不用过于考虑效率问题。
第一次看这个例子时,差不多就知道这些。片元着色器和顶点着色器的代码完全看不懂。
这个例子一共有四个着色器。
a. 片元着色器 birdFS,负责更新鸟群中每只鸟的颜色,最简单
b. 顶点着色器birdVS,负责更新鸟群中每只鸟的姿态和位置坐标,第二难理解
c. 片元着色器fragmentShaderVelocity,负责更新鸟群中每只鸟的速度,相对来说最难理解,
d. 片元着色器fragmentShaderPosition,负责更新鸟群中每只鸟的三维坐标,第二简单
这四个着色器,是透彻理解这个例子绕不过去的。

着色器先放一放,先来讲场景构建,BirdGeometry其实名称不确切,应该叫BirdFlockGeometry.因为这个几何体实际上是描述鸟群的。vertices 属性保存每只鸟的几何顶点,birdColors属性保存每只鸟的顶点颜色,references 属性保存每只鸟在鸟群中的编号,可以通过这个编号找到每只鸟的在纹理图片中的三维坐标和三维速度向量。birdVertex属性保存一只鸟的顶点编号,每只鸟由三个三角面组成,每个三角面又由三个顶点组成。一只鸟就有九个顶点。这个编号就是从 0 到 8,每只鸟都是 0 到 8,这个birdVertex属性 只用于 birdVS 顶点着色器,用于找到鸟翅膀的两个顶点,修改两个顶点 y 坐标,这样每只鸟的一双翅膀就上下扇动起来了。

BirdGeometry的构造函数中,定义了每只鸟的形状;每只鸟顶点颜色,是从深灰到浅灰的不同数值

上图展示了,单只鸟的形状。

场景构建时,这行代码要留意一下 camera.position.z = 350; 摆在了正对着世界坐标的 xy 平面,并且世界坐标的原点位于屏幕的正中心。函数 fillPositionTexture 和 fillVelocityTexture 分别用于初始化每只鸟的位置和速度。

在绘制每一帧前都要调用gpuCompute.compute(),去更新两张 32 x 32像素的纹理图片,每只鸟的位置和速度就变化起来了。这两张纹理然后再传递给 鸟群的顶点着色器birdVS ,更新每只鸟的位置和姿态。

birdFS 中根据每只鸟的位置 z 坐标,来更新鸟的灰度,

varying vec4 vColor;
varying float z;uniform vec3 color;void main() {// Fake colors for nowfloat z2 = 0.2 + ( 1000. - z ) / 1000. * vColor.x;gl_FragColor = vec4( z2, z2, z2, 1. );}

z 越接近相机,越接近350,颜色边深,变暗,超过350,飞到相机后面,看不见了。

birdVS中,

if ( birdVertex == 4.0 || birdVertex == 7.0 ) {// flap wingsnewPosition.y = sin( tmpPos.w ) * 5.;
}

使每只鸟的翅膀上下扇动起来

velocity.z *= -1.;
float xz = length( velocity.xz );
float xyz = 1.;
float x = sqrt( 1. - velocity.y * velocity.y );float cosry = velocity.x / xz;
float sinry = velocity.z / xz;float cosrz = x / xyz;
float sinrz = velocity.y / xyz;

根据速度向量,求方位角 cosry sinry 和俯仰角 cosrz sinrz
假设 velocity 等于 (0, 0, 1.0), 那么 sinry == 1.0;表示需要绕 y轴 旋转90°,进行偏航;
在 BirdGeometry 中对单只鸟的形状构建,可以看到单只鸟的原始朝向就是 (0, 0, 1.0),也就是,velocity 等于 (0, 0, 1.0)时,其实不应该有 偏航;代码中的 576行,birdMesh.rotation.y = Math.PI / 2;
又把这种不一致纠正回来。

newPosition =  maty * matz * newPosition;
newPosition += pos;

每只鸟的每个顶点,先绕z轴 (俯仰角)旋转,再绕y轴(方位角)旋转。

可以看到 在这个 顶点着色器 birdVS 中,求俯仰角 和 方位角的过程相当别扭,不够优雅,不容易理解。我问了自己这两个问题:

  1. 代码 576 行,为什么必须旋转 birdMesh, birdMesh.rotation.y = Math.PI / 2; 可以去掉吗?
  2. 顶点着色器代码 248行,velocity.z *= -1.; 为什么必须反转 z 分量,能不反转吗?
    这两个为什么我都不知道,但是注释掉这两行代码后,鸟的飞行动画就很怪异,鸟有时会倒着飞;头朝下 并且 往上飞,非常好笑。这个例子中,鸟头和鸟尾并不容易区分,可以在另一个例子 webgl_gpgpu_birds_gltf 中注释掉这两行代码,可以有时 鸟飞翔的极其不正常 😃
    为了让 求俯仰角 和 方位角的过程自然点,我花了几乎一天时间,先是写了一个简单的例子;再根据这个例子来推断应该如何修改着色器。结果是这样的:
  3. 首先注释掉 birdMesh.rotation.y = Math.PI / 2;
  4. 用以下代码
float theta = atan(velocity.x, velocity.z);
float phi = acos(clamp(velocity.y, -1.0, 1.0)) - 0.5 * 3.1415926;
float sinY = sin(theta);
float cosY = cos(theta);
float sinX = sin(phi);
float cosX = cos(phi);
mat3 matY =  mat3(cosY, 0, -sinY,0    , 1, 0     ,sinY, 0, cosY );
mat3 matX = mat3(1, 0, 0,0, cosX, sinX,0, -sinX, cosX );newPosition = matY * matX * newPosition;

替换原来的代码 248行 到 272 行

velocity.z *= -1.;
float xz = length( velocity.xz );
float xyz = 1.;
float x = sqrt( 1. - velocity.y * velocity.y );float cosry = velocity.x / xz;
float sinry = velocity.z / xz;float cosrz = x / xyz;
float sinrz = velocity.y / xyz;mat3 maty =  mat3(cosry, 0, -sinry,0    , 1, 0     ,sinry, 0, cosry);mat3 matz =  mat3(cosrz , sinrz, 0,-sinrz, cosrz, 0,0     , 0    , 1
);newPosition =  maty * matz * newPosition;

还有另外一种改法,如下:

float xz = length( velocity.xz );
float xyz = 1.;
float x = sqrt( 1. - velocity.y * velocity.y );float cosry = velocity.z / xz;
float sinry = velocity.x / xz;float cosrx = x / xyz;
float sinrx = -velocity.y / xyz;mat3 maty =  mat3( cosry, 0, -sinry, 0    , 1, 0     , sinry, 0, cosry );
mat3 matX = mat3(1, 0, 0,0, cosrx, sinrx,0, -sinrx, cosrx );newPosition =  maty * matX * newPosition;

特别要注意的地方是 float sinrx = -velocity.y / xyz; 有个负号;我之前一直想当然的认为 向量 (0,0,1)旋转到(0,1,0),是绕 x 轴旋转 正的 90°,其实应该是负的 90°; 向量 (0,0,1)绕 x轴 旋转90°后 变为向量 (0,0,-1)
还有一处改动,

float cosry = velocity.z / xz;
float sinry = velocity.x / xz;

求方位角时,交换了 z 和 x;这处改动比较容易想到。

mat3 matX = mat3(1, 0, 0,0, cosrx, sinrx,0, -sinrx, cosrx );

这是最后一个改动,用绕 x轴的旋转 替换掉绕 z轴的旋转;就像之前说的BirdGeometry在构建单只鸟的形状时,就是让鸟的正面朝向为 正 z轴 (0,0,1),改变鸟的俯仰角本就该绕 x轴旋转,这样才自然

fragmentShaderPosition片元着色器负责更新每只鸟的三维坐标,其中的翅膀摆动的相位变量 phase 保存在 w 分量中,用于在之后的 birdVS 顶点着色器中使用,来更新翅膀的摆动幅度,期望速率越大时,摆动的幅度也越大,频率也越快, 其中的 62.83 约等于 PI 的20倍。

uniform float time;
uniform float delta;void main()	{vec2 uv = gl_FragCoord.xy / resolution.xy;vec4 tmpPos = texture2D( texturePosition, uv );vec3 position = tmpPos.xyz;vec3 velocity = texture2D( textureVelocity, uv ).xyz;float phase = tmpPos.w;phase = mod( ( phase + delta +length( velocity.xz ) * delta * 3. +max( velocity.y, 0.0 ) * delta * 6. ), 62.83 );gl_FragColor = vec4( position + velocity * delta * 15. , phase );}

最后一个最复杂,代码最多的fragmentShaderVelocity片元着色器,更新每只鸟的速度向量。
可以看到优先级最高的是规避 捕食者,让鸟群远离捕食者一定的距离;
可以看作是来自捕食者的排斥力,这是有条件的,只有鸟靠近捕食者一定距离,才会收到这种斥力,
第二优先级是,使鸟始终向着屏幕的中心移动,这些鸟始终都受到来自屏幕中心的引力;如果没有这个力,鸟群就散开了,很快飞到相机看不见的位置了。
紧接着是一个32 * 32的二重循环,来对鸟群中每只鸟应用 来自其他鸟的排斥力,吸引力,偏向力

if ( dist < 0.0001 ) continue;

表示如果当前像素就是自己,直接跑完这次循环

if ( distSquared > zoneRadiusSquared ) continue;

表示这只鸟 离当前自己太远,不会对我产生排斥力,偏向力,吸引力,直接跑完这次循环,忽略掉。
接下来,就是 if … else if … else … 三个分支,其实可以想象一个三个大小不同的圆组成一个同心圆环。最内层的圆表示,如果我自己和其他鸟的距离小于圆半径,则我受到来自这只鸟的排斥力;
如果我自己和其他鸟的距离在最小圆半径 和 次小圆半径之间,则我受到来自这只鸟的偏向力;我的飞行姿态要向这只鸟看齐,如果我自己和其他鸟的距离在次小圆半径 和最大圆半径之间,则受到来自这只鸟的吸引力。

排斥力,偏向力,吸引力三个力是鸟群之间的相互作用力。三个力是互斥的,鸟A 只能受到 鸟B三个力中的一种,也可能 鸟A 和 鸟B之间完全没有相互作用力。三个力的优先级是 排斥力 > 偏向力 > 吸引力。

separationDistance 定义排斥力半径, separationDistance + alignmentDistance的次圆面积 减去 半径为 separationDistance的最小圆面积,得到一个圆环区域;以我自己为圆心,如果其他鸟在这个圆环区域内,则我向这只鸟看齐,受到来自只鸟的偏向力;最大圆的半径是 separationDistance + alignmentDistance + cohesionDistance;最大圆 减去 次小圆又是另一个圆环;这个圆环内小鸟对我产生吸引力

// Attraction / Cohesion - move closer
float threshDelta = 1.0 - alignmentThresh;
float adjustedPercent;
if( threshDelta == 0. ) adjustedPercent = 1.;
else adjustedPercent = ( percent - alignmentThresh ) / threshDelta;f = ( 0.5 - ( cos( adjustedPercent * PI_2 ) * -0.5 + 0.5 ) ) * delta;velocity += normalize( dir ) * f;

上面代码里还考虑了除零异常。cohesionDistance是允许为零的,为零时,f = 1.5 * delta;
delta表示前一帧和当前帧之间流逝了多少时间,以毫秒为单位;

代码中当 cohesionDistance == 0, 并且 alignmentDistance == 0,当percent == 1时,直接进入else分支,这时鸟群之间没有偏向力,只有吸引力和排斥力两种。

最后附上这个例子的完整代码:

<!DOCTYPE html>
<html lang="en"><head><title>three.js webgl - gpgpu - flocking</title><meta charset="utf-8"><meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"><link type="text/css" rel="stylesheet" href="main.css"><style>body {background-color: #fff;color: #444;}a {color:#08f;}</style></head><body><div id="info"><a href="" target="_blank" rel="noopener">three.js</a> - webgl gpgpu birds<br/>Move mouse to disturb birds.</div><!--TODO: If you're reading this, you may wish to improve this example by- Create a better shading for the birds?--><!-- shader for bird's position --><script id="fragmentShaderPosition" type="x-shader/x-fragment">uniform float time;uniform float delta;void main()	{vec2 uv = gl_FragCoord.xy / resolution.xy;vec4 tmpPos = texture2D( texturePosition, uv );vec3 position = tmpPos.xyz;vec3 velocity = texture2D( textureVelocity, uv ).xyz;float phase = tmpPos.w;phase = mod( ( phase + delta +length( velocity.xz ) * delta * 3. +max( velocity.y, 0.0 ) * delta * 6. ), 62.83 );gl_FragColor = vec4( position + velocity * delta * 15. , phase );}</script><!-- shader for bird's velocity --><script id="fragmentShaderVelocity" type="x-shader/x-fragment">uniform float time;uniform float testing;uniform float delta; // about 0.016uniform float separationDistance; // 20uniform float alignmentDistance; // 40uniform float cohesionDistance; //uniform float freedomFactor;uniform vec3 predator;const float width = resolution.x;const float height = resolution.y;const float PI = 3.141592653589793;const float PI_2 = PI * 2.0;// const float VISION = PI * 0.55;float zoneRadius = 40.0;float zoneRadiusSquared = 1600.0;float separationThresh = 0.45;float alignmentThresh = 0.65;const float UPPER_BOUNDS = BOUNDS;const float LOWER_BOUNDS = -UPPER_BOUNDS;const float SPEED_LIMIT = 9.0;float rand( vec2 co ){return fract( sin( dot( co.xy, vec2(12.9898,78.233) ) ) * 43758.5453 );}void main() {zoneRadius = separationDistance + alignmentDistance + cohesionDistance;separationThresh = separationDistance / zoneRadius;alignmentThresh = ( separationDistance + alignmentDistance ) / zoneRadius;zoneRadiusSquared = zoneRadius * zoneRadius;vec2 uv = gl_FragCoord.xy / resolution.xy;vec3 birdPosition, birdVelocity;vec3 selfPosition = texture2D( texturePosition, uv ).xyz;vec3 selfVelocity = texture2D( textureVelocity, uv ).xyz;float dist;vec3 dir; // directionfloat distSquared;float separationSquared = separationDistance * separationDistance;float cohesionSquared = cohesionDistance * cohesionDistance;float f;float percent;vec3 velocity = selfVelocity;float limit = SPEED_LIMIT;dir = predator * UPPER_BOUNDS - selfPosition;dir.z = 0.;// dir.z *= 0.6;dist = length( dir );distSquared = dist * dist;float preyRadius = 150.0;float preyRadiusSq = preyRadius * preyRadius;// move birds away from predatorif ( dist < preyRadius ) {f = ( distSquared / preyRadiusSq - 1.0 ) * delta * 100.;velocity += normalize( dir ) * f;limit += 5.0;}// if (testing == 0.0) {}// if ( rand( uv + time ) < freedomFactor ) {}// Attract flocks to the centervec3 central = vec3( 0., 0., 0. );dir = selfPosition - central;dist = length( dir );dir.y *= 2.5;velocity -= normalize( dir ) * delta * 5.;for ( float y = 0.0; y < height; y++ ) {for ( float x = 0.0; x < width; x++ ) {vec2 ref = vec2( x + 0.5, y + 0.5 ) / resolution.xy;birdPosition = texture2D( texturePosition, ref ).xyz;dir = birdPosition - selfPosition;dist = length( dir );if ( dist < 0.0001 ) continue;distSquared = dist * dist;if ( distSquared > zoneRadiusSquared ) continue;percent = distSquared / zoneRadiusSquared;if ( percent < separationThresh ) { // low// Separation - Move apart for comfortf = ( separationThresh / percent - 1.0 ) * delta;velocity -= normalize( dir ) * f;} else if ( percent < alignmentThresh ) { // high// Alignment - fly the same directionfloat threshDelta = alignmentThresh - separationThresh;float adjustedPercent = ( percent - separationThresh ) / threshDelta;birdVelocity = texture2D( textureVelocity, ref ).xyz;f = ( 0.5 - cos( adjustedPercent * PI_2 ) * 0.5 + 0.5 ) * delta;velocity += normalize( birdVelocity ) * f;} else {// Attraction / Cohesion - move closerfloat threshDelta = 1.0 - alignmentThresh;float adjustedPercent;if( threshDelta == 0. ) adjustedPercent = 1.;else adjustedPercent = ( percent - alignmentThresh ) / threshDelta;f = ( 0.5 - ( cos( adjustedPercent * PI_2 ) * -0.5 + 0.5 ) ) * delta;velocity += normalize( dir ) * f;}}}// this make tends to fly around than down or up// if (velocity.y > 0.) velocity.y *= (1. - 0.2 * delta);// Speed Limitsif ( length( velocity ) > limit ) {velocity = normalize( velocity ) * limit;}gl_FragColor = vec4( velocity, 1.0 );}</script><script type="x-shader/x-vertex" id="birdVS">attribute vec2 reference;attribute float birdVertex;attribute vec3 birdColor;uniform sampler2D texturePosition;uniform sampler2D textureVelocity;varying vec4 vColor;varying float z;uniform float time;void main() {vec4 tmpPos = texture2D( texturePosition, reference );vec3 pos = tmpPos.xyz;vec3 velocity = normalize(texture2D( textureVelocity, reference ).xyz);vec3 newPosition = position;if ( birdVertex == 4.0 || birdVertex == 7.0 ) {// flap wingsnewPosition.y = sin( tmpPos.w ) * 5.;}newPosition = mat3( modelMatrix ) * newPosition;float xz = length( velocity.xz );float xyz = 1.;float x = sqrt( 1. - velocity.y * velocity.y );float cosry = velocity.z / xz;float sinry = velocity.x / xz;float cosrx = x / xyz;float sinrx = -velocity.y / xyz;mat3 maty =  mat3(cosry, 0, -sinry,0    , 1, 0     ,sinry, 0, cosry);mat3 matX = mat3(1, 0, 0,0, cosrx, sinrx,0, -sinrx, cosrx );newPosition =  maty * matX * newPosition;newPosition += pos;z = newPosition.z;vColor = vec4( birdColor, 1.0 );gl_Position = projectionMatrix *  viewMatrix  * vec4( newPosition, 1.0 );}</script><!-- bird geometry shader --><script type="x-shader/x-fragment" id="birdFS">varying vec4 vColor;varying float z;uniform vec3 color;void main() {// Fake colors for nowfloat z2 = 0.2 + ( 1000. - z ) / 1000. * vColor.x;gl_FragColor = vec4( z2, z2, z2, 1. );}</script><!-- Import maps polyfill --><!-- Remove this when import maps will be widely supported --><script async src="@1.3.6/dist/es-module-shims.js"></script><script type="importmap">{"imports": {"three": "../build/three.module.js","three/addons/": "./jsm/"}}</script><script type="module">import * as THREE from 'three';import Stats from 'three/addons/libs/stats.module.js';import { GUI } from 'three/addons/libs/lil-gui.module.min.js';import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js';/* TEXTURE WIDTH FOR SIMULATION */const WIDTH = 32;const BIRDS = WIDTH * WIDTH;// Custom Geometry - using 3 triangles each. No UVs, no normals currently.class BirdGeometry extends THREE.BufferGeometry {constructor() {super();const trianglesPerBird = 3;const triangles = BIRDS * trianglesPerBird;const points = triangles * 3;const vertices = new THREE.BufferAttribute( new Float32Array( points * 3 ), 3 );const birdColors = new THREE.BufferAttribute( new Float32Array( points * 3 ), 3 );const references = new THREE.BufferAttribute( new Float32Array( points * 2 ), 2 );const birdVertex = new THREE.BufferAttribute( new Float32Array( points ), 1 );this.setAttribute( 'position', vertices );this.setAttribute( 'birdColor', birdColors );this.setAttribute( 'reference', references );this.setAttribute( 'birdVertex', birdVertex );// this.setAttribute( 'normal', new Float32Array( points * 3 ), 3 );let v = 0;function verts_push() {for ( let i = 0; i < arguments.length; i ++ ) {vertices.array[ v ++ ] = arguments[ i ];}}const wingsSpan = 20;for ( let f = 0; f < BIRDS; f ++ ) {// Bodyverts_push(0, - 0, - 20,0, 4, - 20,0, 0, 30);// Wingsverts_push(0, 0, - 15,- wingsSpan, 0, 0,0, 0, 15);verts_push(0, 0, 15,wingsSpan, 0, 0,0, 0, - 15);}for ( let v = 0; v < triangles * 3; v ++ ) {const triangleIndex = ~ ~ ( v / 3 );const birdIndex = ~ ~ ( triangleIndex / trianglesPerBird );const x = ( birdIndex % WIDTH ) / WIDTH;const y = ~ ~ ( birdIndex / WIDTH ) / WIDTH;const c = new THREE.Color(0x444444 +~ ~ ( v / 9 ) / BIRDS * 0x666666);birdColors.array[ v * 3 + 0 ] = c.r;birdColors.array[ v * 3 + 1 ] = c.g;birdColors.array[ v * 3 + 2 ] = c.b;references.array[ v * 2 ] = x;references.array[ v * 2 + 1 ] = y;birdVertex.array[ v ] = v % 9;}this.scale( 0.2, 0.2, 0.2 );}}//let container, stats;let camera, scene, renderer;let mouseX = 0, mouseY = 0;let windowHalfX = window.innerWidth / 2;let windowHalfY = window.innerHeight / 2;const BOUNDS = 800, BOUNDS_HALF = BOUNDS / 2;let last = performance.now();let gpuCompute;let velocityVariable;let positionVariable;let positionUniforms;let velocityUniforms;let birdUniforms;init();animate();function init() {container = document.createElement( 'div' );document.body.appendChild( container );camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 1, 3000 );camera.position.z = 350;scene = new THREE.Scene();scene.background = new THREE.Color( 0xffffff );scene.fog = new THREE.Fog( 0xffffff, 100, 1000 );renderer = new THREE.WebGLRenderer();renderer.setPixelRatio( window.devicePixelRatio );renderer.setSize( window.innerWidth, window.innerHeight );container.appendChild( renderer.domElement );initComputeRenderer();stats = new Stats();container.appendChild( stats.dom );container.style.touchAction = 'none';container.addEventListener( 'pointermove', onPointerMove );//window.addEventListener( 'resize', onWindowResize );const gui = new GUI();const effectController = {separation: 20.0,alignment: 20.0,cohesion: 20.0,freedom: 0.75};const valuesChanger = function () {velocityUniforms[ 'separationDistance' ].value = effectController.separation;velocityUniforms[ 'alignmentDistance' ].value = effectController.alignment;velocityUniforms[ 'cohesionDistance' ].value = effectController.cohesion;velocityUniforms[ 'freedomFactor' ].value = effectController.freedom;};valuesChanger();gui.add( effectController, 'separation', 0.0, 100.0, 1.0 ).onChange( valuesChanger );gui.add( effectController, 'alignment', 0.0, 100, 0.001 ).onChange( valuesChanger );gui.add( effectController, 'cohesion', 0.0, 100, 0.025 ).onChange( valuesChanger );gui.close();initBirds();}function initComputeRenderer() {gpuCompute = new GPUComputationRenderer( WIDTH, WIDTH, renderer );if ( renderer.capabilities.isWebGL2 === false ) {gpuCompute.setDataType( THREE.HalfFloatType );}const dtPosition = gpuCompute.createTexture();const dtVelocity = gpuCompute.createTexture();fillPositionTexture( dtPosition );fillVelocityTexture( dtVelocity );velocityVariable = gpuCompute.addVariable( 'textureVelocity', document.getElementById( 'fragmentShaderVelocity' ).textContent, dtVelocity );positionVariable = gpuCompute.addVariable( 'texturePosition', document.getElementById( 'fragmentShaderPosition' ).textContent, dtPosition );gpuCompute.setVariableDependencies( velocityVariable, [ positionVariable, velocityVariable ] );gpuCompute.setVariableDependencies( positionVariable, [ positionVariable, velocityVariable ] );positionUniforms = positionVariable.material.uniforms;velocityUniforms = velocityVariable.material.uniforms;positionUniforms[ 'time' ] = { value: 0.0 };positionUniforms[ 'delta' ] = { value: 0.0 };velocityUniforms[ 'time' ] = { value: 1.0 };velocityUniforms[ 'delta' ] = { value: 0.0 };velocityUniforms[ 'testing' ] = { value: 1.0 };velocityUniforms[ 'separationDistance' ] = { value: 1.0 };velocityUniforms[ 'alignmentDistance' ] = { value: 1.0 };velocityUniforms[ 'cohesionDistance' ] = { value: 1.0 };velocityUniforms[ 'freedomFactor' ] = { value: 1.0 };velocityUniforms[ 'predator' ] = { value: new THREE.Vector3() };velocityVariable.material.defines.BOUNDS = BOUNDS.toFixed( 2 );velocityVariable.wrapS = THREE.RepeatWrapping;velocityVariable.wrapT = THREE.RepeatWrapping;positionVariable.wrapS = THREE.RepeatWrapping;positionVariable.wrapT = THREE.RepeatWrapping;const error = gpuCompute.init();if ( error !== null ) {console.error( error );}}function initBirds() {const geometry = new BirdGeometry();// For Vertex and FragmentbirdUniforms = {'color': { value: new THREE.Color( 0xff2200 ) },'texturePosition': { value: null },'textureVelocity': { value: null },'time': { value: 1.0 },'delta': { value: 0.0 }};// THREE.ShaderMaterialconst material = new THREE.ShaderMaterial( {uniforms: birdUniforms,vertexShader: document.getElementById( 'birdVS' ).textContent,fragmentShader: document.getElementById( 'birdFS' ).textContent,side: THREE.DoubleSide} );const birdMesh = new THREE.Mesh( geometry, material );birdMesh.matrixAutoUpdate = false;birdMesh.updateMatrix();scene.add( birdMesh );}function fillPositionTexture( texture ) {const theArray = texture.image.data;for ( let k = 0, kl = theArray.length; k < kl; k += 4 ) {const x = Math.random() * BOUNDS - BOUNDS_HALF;const y = Math.random() * BOUNDS - BOUNDS_HALF;const z = Math.random() * BOUNDS - BOUNDS_HALF;theArray[ k + 0 ] = x;theArray[ k + 1 ] = y;theArray[ k + 2 ] = z;theArray[ k + 3 ] = 1;}}function fillVelocityTexture( texture ) {const theArray = texture.image.data;for ( let k = 0, kl = theArray.length; k < kl; k += 4 ) {const x = Math.random() - 0.5;const y = Math.random() - 0.5;const z = Math.random() - 0.5;theArray[ k + 0 ] = x * 10;theArray[ k + 1 ] = y * 10;theArray[ k + 2 ] = z * 10;theArray[ k + 3 ] = 1;}}function onWindowResize() {windowHalfX = window.innerWidth / 2;windowHalfY = window.innerHeight / 2;camera.aspect = window.innerWidth / window.innerHeight;camera.updateProjectionMatrix();renderer.setSize( window.innerWidth, window.innerHeight );}function onPointerMove( event ) {if ( event.isPrimary === false ) return;mouseX = event.clientX - windowHalfX;mouseY = event.clientY - windowHalfY;}//function animate() {requestAnimationFrame( animate );render();stats.update();}function render() {const now = performance.now();let delta = ( now - last ) / 1000;if ( delta > 1 ) delta = 1; // safety cap on large deltaslast = now;positionUniforms[ 'time' ].value = now;positionUniforms[ 'delta' ].value = delta;velocityUniforms[ 'time' ].value = now;velocityUniforms[ 'delta' ].value = delta;birdUniforms[ 'time' ].value = now;birdUniforms[ 'delta' ].value = delta;velocityUniforms[ 'predator' ].value.set( 0.5 * mouseX / windowHalfX, - 0.5 * mouseY / windowHalfY, 0 );mouseX = 10000;mouseY = 10000;gpuCompute.compute();birdUniforms[ 'texturePosition' ].value = gpuCompute.getCurrentRenderTarget( positionVariable ).texture;birdUniforms[ 'textureVelocity' ].value = gpuCompute.getCurrentRenderTarget( velocityVariable ).texture;renderer.render( scene, camera );}</script></body>
</html>

下面代码是为研究朝向问题,写的一个小例子,TestBirdRotate.html

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>bird rotate</title><style>body {margin: 0;padding: 0;overflow: hidden;}</style><script src="../lib/dat.gui.min.js"></script>
</head>
<body>
<script type="module">import * as THREE from '../lib/three.module.js'import {OrbitControls} from "../lib/jsm/OrbitControls.js"const champagne = '#fff9b1'const creamy_white = '#f0ebd5'let camera, scene, renderer;let cameraControlslet screenconst raycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3());let birdRotating = false;let trackMeshconst iBirdMesh = new THREE.Group()const sphere = new THREE.Sphere(new THREE.Vector3(0,0,0), 1)const sphereIntersect = new THREE.Vector3()const r2d = 180 / Math.PI;const pi = Math.PI;let objRx, objRy, objRzinit()animate()function init() {const width = window.innerWidthconst height = window.innerHeightscreen = {left: 0,top: 0,width: width,height: height}camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 10 )camera.position.set(-2, 1.5, 2.5)scene = new THREE.Scene()scene.add(new THREE.AxesHelper(2000))scene.background = new THREE.Color( 0, 0, 0 )renderer = new THREE.WebGLRenderer({antialias : true})renderer.setSize( window.innerWidth, window.innerHeight )document.body.appendChild( renderer.domElement )cameraControls = new OrbitControls(camera, renderer.domElement);cameraControls.minDistance = 1;cameraControls.maxDistance = 100;window.addEventListener( 'resize', onWindowResize );initMeshes();raycaster.camera = camera;initListeners();setUpGui();}function initMeshes() {const wingsSpan = 20;const vertices = [// body0, - 0, - 20,0, 4, - 20,0, 0, 30,// wings0, 0, - 15,- wingsSpan, 0, 0,0, 0, 15,0, 0, 15,wingsSpan, 0, 0,0, 0, -15,// ray test line// 0,0,0,// 0,0.1,0,// 0,0,120,];const birdGeometry = new THREE.BufferGeometry();birdGeometry.addAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));const birdMtl = new THREE.MeshBasicMaterial({color: champagne, wireframe: true});const birdMesh = new THREE.Mesh(birdGeometry, birdMtl);scene.add(iBirdMesh);iBirdMesh.add(birdMesh);birdMesh.scale.set(0.022, 0.022, 0.022);// birdMesh.rotation.y = Math.PI / 2;const ballGeo = new THREE.SphereGeometry(1);const ballMtl = new THREE.MeshBasicMaterial({color: '#02230a', wireframe: true})const ballMesh = new THREE.Mesh(ballGeo, ballMtl);scene.add(ballMesh)const ballGeo2 = new THREE.SphereGeometry(0.03, 2, 2)const ballMtl2 = new THREE.MeshBasicMaterial({color: '#b0d', wireframe: true})trackMesh = new THREE.Mesh(ballGeo2, ballMtl2);}function initListeners() {document.addEventListener('mouseup', () => {});document.addEventListener('mousemove', (event) => {if (!birdRotating) {return}updateRaycaster(event.clientX, event.clientY);raycaster.ray.intersectSphere(sphere, sphereIntersect)trackMesh.position.copy(sphereIntersect)// console.log("ball intersects: ", sphereIntersect)// 拷贝来源 THREE.Spherical.setFromCartesianCoords( x, y, z )// 方位角θ 是在xz平面 目标法向量与 (0,0,1)的夹角,如果目标法向为(0,0,1)则为 90°,如果目标法向为(0,0,-1)则为 -90°,// 俯仰角φ 目标法向量与 (0,1,0)的夹角,如果目标法向接近(0,1,0)则俯仰角φ 接近 0,如果目标法向接近(0,-1,0),则俯仰角φ 接近 180°const {x, y, z} = sphereIntersectconst theta = Math.atan2(x, z)const thetaDeg = theta * r2dconst phi = Math.acos( THREE.MathUtils.clamp( y , - 1, 1 ) ) - Math.PI * 0.5const phiDeg = phi * r2d// console.log("thetaDeg: " + thetaDeg + ", phiDeg: " + phiDeg)// iBirdMesh.rotation.y = theta// iBirdMesh.children[0].rotation.x = phi// -------------------------------------------// iBirdMesh.children[0].rotation.y = theta// iBirdMesh.rotation.x = phiconst matX = new THREE.Matrix4().makeRotationX(phi)const matY = new THREE.Matrix4().makeRotationY(theta)iBirdMesh.children[0].quaternion.set(0, 0, 0, 1)const mat = matY.multiply(matX)iBirdMesh.children[0].applyMatrix4(mat)// iBirdMesh.children[0].applyMatrix4(matX)// iBirdMesh.children[0].applyMatrix4(matY)const rot = iBirdMesh.children[0].rotationobjRx.setValue(rot.x)objRy.setValue(rot.y)objRz.setValue(rot.z)// iBirdMesh.children[0].quaternion.set(0, 0, 0, 1)// iBirdMesh.children[0].rotateOnAxis(new THREE.Vector3(0,1,0), theta)// iBirdMesh.children[0].rotateOnAxis(new THREE.Vector3(1,0,0), phi)});}function updateRaycaster(x, y) {const vector = new THREE.Vector3(( ( x - screen.left ) / screen.width ) * 2 - 1, -( ( y - screen.top ) / screen.height ) * 2 + 1, 0.5);vector.unproject(camera);raycaster.ray.origin.copy(camera.position);raycaster.ray.direction.copy(vector).sub(camera.position).normalize();}function setUpGui() {const guiData = {rx: 0,ry: 0,rz: 0,followMouse: birdRotating,resetCamera: function () {cameraControls.reset()},resetRotation: function () {const birdMesh = iBirdMesh.children[0]birdMesh.rotation.set(0, 0, 0)iBirdMesh.rotation.set(0, 0, 0)// guiData.rx = guiData.ry = guiData.rz = 0objRx.setValue(0)objRy.setValue(0)objRz.setValue(0)},}const gui = new dat.GUI();objRx = gui.add(guiData, 'rx', -pi, pi, 0.01).onChange(function (e) {iBirdMesh.children[0].rotation.x = e});objRy = gui.add(guiData, 'ry', -pi, pi, 0.01).onChange(function (e) {iBirdMesh.children[0].rotation.y = e});objRz = gui.add(guiData, 'rz', -pi, pi, 0.01).onChange(function (e) {iBirdMesh.children[0].rotation.z = e});gui.add(guiData, 'followMouse').name('跟随鼠标').onChange(function (e) {birdRotating = eif (!trackMesh.parent) {scene.add(trackMesh)}});gui.add(guiData, 'resetCamera').name("重置相机");gui.add(guiData, 'resetRotation').name("重置旋转");}function onWindowResize() {camera.aspect = window.innerWidth / window.innerHeight;camera.updateProjectionMatrix();renderer.setSize( window.innerWidth, window.innerHeight );}function animate() {cameraControls.update();renderer.render( scene, camera );requestAnimationFrame( animate );}
</script>
</body>
</html>

webgl

webgl_gpgpu_birds 是一个 three.js 的官方样例,这个例子模拟了鸟群的运动,是一个群组动画,并且动画的帧率也很高;鸟群的运动很自然,非常值得研究。类似的群组动画还有鱼群,boid是‘类鸟群’的英文

大概两年前,第一次看这个例子时,很枯燥,看不懂,有很多地方不知道是什么意思。第一次看这个例子时,才知道原来 纹理 texture 可以这样来使用,这个例子可以作为一个通用的并行计算框架的例子来看待。

这个例子的鸟群中一共有 32 x 32 共 1024 只鸟。鸟群中的每只鸟的位置是一个三维坐标,保存在一张 32 x 32 像素的纹理图片缓存中,初始化时,fillPositionTexture函数负责为 每只鸟 赋予一个 [-400, 400] 闭区间内的一个随机值。鸟的位置的x y z 分量都在 -400 到 400内取随机值。

鸟群中的每只鸟的速度是一个三维向量,保存在另外一张 32 x 32 像素的纹理图片缓存中,初始化时,fillVelocityTexture函数负责为 每只鸟 的速度的x y z分向量都赋予一个 [-5, 5] 闭区间内的一个随机值。每只鸟的速率和方向都是不同的。

例子中的 GPUComputationRenderer 负责在每一帧渲染前,都去以一定的规律 或 计算模式去更新鸟群的位置纹理 和 速度纹理。这两个纹理的分辨率都是 32 x 32 像素,对于渲染器来说,分辨率很小。渲染器更新的纹理的分辨率一般都是屏幕分辨率,1024 * 768 等;所以,更新这两张贴图对于渲染器来说很轻量,写这两张纹理对应的片元着色器代码时,不用过于考虑效率问题。
第一次看这个例子时,差不多就知道这些。片元着色器和顶点着色器的代码完全看不懂。
这个例子一共有四个着色器。
a. 片元着色器 birdFS,负责更新鸟群中每只鸟的颜色,最简单
b. 顶点着色器birdVS,负责更新鸟群中每只鸟的姿态和位置坐标,第二难理解
c. 片元着色器fragmentShaderVelocity,负责更新鸟群中每只鸟的速度,相对来说最难理解,
d. 片元着色器fragmentShaderPosition,负责更新鸟群中每只鸟的三维坐标,第二简单
这四个着色器,是透彻理解这个例子绕不过去的。

着色器先放一放,先来讲场景构建,BirdGeometry其实名称不确切,应该叫BirdFlockGeometry.因为这个几何体实际上是描述鸟群的。vertices 属性保存每只鸟的几何顶点,birdColors属性保存每只鸟的顶点颜色,references 属性保存每只鸟在鸟群中的编号,可以通过这个编号找到每只鸟的在纹理图片中的三维坐标和三维速度向量。birdVertex属性保存一只鸟的顶点编号,每只鸟由三个三角面组成,每个三角面又由三个顶点组成。一只鸟就有九个顶点。这个编号就是从 0 到 8,每只鸟都是 0 到 8,这个birdVertex属性 只用于 birdVS 顶点着色器,用于找到鸟翅膀的两个顶点,修改两个顶点 y 坐标,这样每只鸟的一双翅膀就上下扇动起来了。

BirdGeometry的构造函数中,定义了每只鸟的形状;每只鸟顶点颜色,是从深灰到浅灰的不同数值

上图展示了,单只鸟的形状。

场景构建时,这行代码要留意一下 camera.position.z = 350; 摆在了正对着世界坐标的 xy 平面,并且世界坐标的原点位于屏幕的正中心。函数 fillPositionTexture 和 fillVelocityTexture 分别用于初始化每只鸟的位置和速度。

在绘制每一帧前都要调用gpuCompute.compute(),去更新两张 32 x 32像素的纹理图片,每只鸟的位置和速度就变化起来了。这两张纹理然后再传递给 鸟群的顶点着色器birdVS ,更新每只鸟的位置和姿态。

birdFS 中根据每只鸟的位置 z 坐标,来更新鸟的灰度,

varying vec4 vColor;
varying float z;uniform vec3 color;void main() {// Fake colors for nowfloat z2 = 0.2 + ( 1000. - z ) / 1000. * vColor.x;gl_FragColor = vec4( z2, z2, z2, 1. );}

z 越接近相机,越接近350,颜色边深,变暗,超过350,飞到相机后面,看不见了。

birdVS中,

if ( birdVertex == 4.0 || birdVertex == 7.0 ) {// flap wingsnewPosition.y = sin( tmpPos.w ) * 5.;
}

使每只鸟的翅膀上下扇动起来

velocity.z *= -1.;
float xz = length( velocity.xz );
float xyz = 1.;
float x = sqrt( 1. - velocity.y * velocity.y );float cosry = velocity.x / xz;
float sinry = velocity.z / xz;float cosrz = x / xyz;
float sinrz = velocity.y / xyz;

根据速度向量,求方位角 cosry sinry 和俯仰角 cosrz sinrz
假设 velocity 等于 (0, 0, 1.0), 那么 sinry == 1.0;表示需要绕 y轴 旋转90°,进行偏航;
在 BirdGeometry 中对单只鸟的形状构建,可以看到单只鸟的原始朝向就是 (0, 0, 1.0),也就是,velocity 等于 (0, 0, 1.0)时,其实不应该有 偏航;代码中的 576行,birdMesh.rotation.y = Math.PI / 2;
又把这种不一致纠正回来。

newPosition =  maty * matz * newPosition;
newPosition += pos;

每只鸟的每个顶点,先绕z轴 (俯仰角)旋转,再绕y轴(方位角)旋转。

可以看到 在这个 顶点着色器 birdVS 中,求俯仰角 和 方位角的过程相当别扭,不够优雅,不容易理解。我问了自己这两个问题:

  1. 代码 576 行,为什么必须旋转 birdMesh, birdMesh.rotation.y = Math.PI / 2; 可以去掉吗?
  2. 顶点着色器代码 248行,velocity.z *= -1.; 为什么必须反转 z 分量,能不反转吗?
    这两个为什么我都不知道,但是注释掉这两行代码后,鸟的飞行动画就很怪异,鸟有时会倒着飞;头朝下 并且 往上飞,非常好笑。这个例子中,鸟头和鸟尾并不容易区分,可以在另一个例子 webgl_gpgpu_birds_gltf 中注释掉这两行代码,可以有时 鸟飞翔的极其不正常 😃
    为了让 求俯仰角 和 方位角的过程自然点,我花了几乎一天时间,先是写了一个简单的例子;再根据这个例子来推断应该如何修改着色器。结果是这样的:
  3. 首先注释掉 birdMesh.rotation.y = Math.PI / 2;
  4. 用以下代码
float theta = atan(velocity.x, velocity.z);
float phi = acos(clamp(velocity.y, -1.0, 1.0)) - 0.5 * 3.1415926;
float sinY = sin(theta);
float cosY = cos(theta);
float sinX = sin(phi);
float cosX = cos(phi);
mat3 matY =  mat3(cosY, 0, -sinY,0    , 1, 0     ,sinY, 0, cosY );
mat3 matX = mat3(1, 0, 0,0, cosX, sinX,0, -sinX, cosX );newPosition = matY * matX * newPosition;

替换原来的代码 248行 到 272 行

velocity.z *= -1.;
float xz = length( velocity.xz );
float xyz = 1.;
float x = sqrt( 1. - velocity.y * velocity.y );float cosry = velocity.x / xz;
float sinry = velocity.z / xz;float cosrz = x / xyz;
float sinrz = velocity.y / xyz;mat3 maty =  mat3(cosry, 0, -sinry,0    , 1, 0     ,sinry, 0, cosry);mat3 matz =  mat3(cosrz , sinrz, 0,-sinrz, cosrz, 0,0     , 0    , 1
);newPosition =  maty * matz * newPosition;

还有另外一种改法,如下:

float xz = length( velocity.xz );
float xyz = 1.;
float x = sqrt( 1. - velocity.y * velocity.y );float cosry = velocity.z / xz;
float sinry = velocity.x / xz;float cosrx = x / xyz;
float sinrx = -velocity.y / xyz;mat3 maty =  mat3( cosry, 0, -sinry, 0    , 1, 0     , sinry, 0, cosry );
mat3 matX = mat3(1, 0, 0,0, cosrx, sinrx,0, -sinrx, cosrx );newPosition =  maty * matX * newPosition;

特别要注意的地方是 float sinrx = -velocity.y / xyz; 有个负号;我之前一直想当然的认为 向量 (0,0,1)旋转到(0,1,0),是绕 x 轴旋转 正的 90°,其实应该是负的 90°; 向量 (0,0,1)绕 x轴 旋转90°后 变为向量 (0,0,-1)
还有一处改动,

float cosry = velocity.z / xz;
float sinry = velocity.x / xz;

求方位角时,交换了 z 和 x;这处改动比较容易想到。

mat3 matX = mat3(1, 0, 0,0, cosrx, sinrx,0, -sinrx, cosrx );

这是最后一个改动,用绕 x轴的旋转 替换掉绕 z轴的旋转;就像之前说的BirdGeometry在构建单只鸟的形状时,就是让鸟的正面朝向为 正 z轴 (0,0,1),改变鸟的俯仰角本就该绕 x轴旋转,这样才自然

fragmentShaderPosition片元着色器负责更新每只鸟的三维坐标,其中的翅膀摆动的相位变量 phase 保存在 w 分量中,用于在之后的 birdVS 顶点着色器中使用,来更新翅膀的摆动幅度,期望速率越大时,摆动的幅度也越大,频率也越快, 其中的 62.83 约等于 PI 的20倍。

uniform float time;
uniform float delta;void main()	{vec2 uv = gl_FragCoord.xy / resolution.xy;vec4 tmpPos = texture2D( texturePosition, uv );vec3 position = tmpPos.xyz;vec3 velocity = texture2D( textureVelocity, uv ).xyz;float phase = tmpPos.w;phase = mod( ( phase + delta +length( velocity.xz ) * delta * 3. +max( velocity.y, 0.0 ) * delta * 6. ), 62.83 );gl_FragColor = vec4( position + velocity * delta * 15. , phase );}

最后一个最复杂,代码最多的fragmentShaderVelocity片元着色器,更新每只鸟的速度向量。
可以看到优先级最高的是规避 捕食者,让鸟群远离捕食者一定的距离;
可以看作是来自捕食者的排斥力,这是有条件的,只有鸟靠近捕食者一定距离,才会收到这种斥力,
第二优先级是,使鸟始终向着屏幕的中心移动,这些鸟始终都受到来自屏幕中心的引力;如果没有这个力,鸟群就散开了,很快飞到相机看不见的位置了。
紧接着是一个32 * 32的二重循环,来对鸟群中每只鸟应用 来自其他鸟的排斥力,吸引力,偏向力

if ( dist < 0.0001 ) continue;

表示如果当前像素就是自己,直接跑完这次循环

if ( distSquared > zoneRadiusSquared ) continue;

表示这只鸟 离当前自己太远,不会对我产生排斥力,偏向力,吸引力,直接跑完这次循环,忽略掉。
接下来,就是 if … else if … else … 三个分支,其实可以想象一个三个大小不同的圆组成一个同心圆环。最内层的圆表示,如果我自己和其他鸟的距离小于圆半径,则我受到来自这只鸟的排斥力;
如果我自己和其他鸟的距离在最小圆半径 和 次小圆半径之间,则我受到来自这只鸟的偏向力;我的飞行姿态要向这只鸟看齐,如果我自己和其他鸟的距离在次小圆半径 和最大圆半径之间,则受到来自这只鸟的吸引力。

排斥力,偏向力,吸引力三个力是鸟群之间的相互作用力。三个力是互斥的,鸟A 只能受到 鸟B三个力中的一种,也可能 鸟A 和 鸟B之间完全没有相互作用力。三个力的优先级是 排斥力 > 偏向力 > 吸引力。

separationDistance 定义排斥力半径, separationDistance + alignmentDistance的次圆面积 减去 半径为 separationDistance的最小圆面积,得到一个圆环区域;以我自己为圆心,如果其他鸟在这个圆环区域内,则我向这只鸟看齐,受到来自只鸟的偏向力;最大圆的半径是 separationDistance + alignmentDistance + cohesionDistance;最大圆 减去 次小圆又是另一个圆环;这个圆环内小鸟对我产生吸引力

// Attraction / Cohesion - move closer
float threshDelta = 1.0 - alignmentThresh;
float adjustedPercent;
if( threshDelta == 0. ) adjustedPercent = 1.;
else adjustedPercent = ( percent - alignmentThresh ) / threshDelta;f = ( 0.5 - ( cos( adjustedPercent * PI_2 ) * -0.5 + 0.5 ) ) * delta;velocity += normalize( dir ) * f;

上面代码里还考虑了除零异常。cohesionDistance是允许为零的,为零时,f = 1.5 * delta;
delta表示前一帧和当前帧之间流逝了多少时间,以毫秒为单位;

代码中当 cohesionDistance == 0, 并且 alignmentDistance == 0,当percent == 1时,直接进入else分支,这时鸟群之间没有偏向力,只有吸引力和排斥力两种。

最后附上这个例子的完整代码:

<!DOCTYPE html>
<html lang="en"><head><title>three.js webgl - gpgpu - flocking</title><meta charset="utf-8"><meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"><link type="text/css" rel="stylesheet" href="main.css"><style>body {background-color: #fff;color: #444;}a {color:#08f;}</style></head><body><div id="info"><a href="" target="_blank" rel="noopener">three.js</a> - webgl gpgpu birds<br/>Move mouse to disturb birds.</div><!--TODO: If you're reading this, you may wish to improve this example by- Create a better shading for the birds?--><!-- shader for bird's position --><script id="fragmentShaderPosition" type="x-shader/x-fragment">uniform float time;uniform float delta;void main()	{vec2 uv = gl_FragCoord.xy / resolution.xy;vec4 tmpPos = texture2D( texturePosition, uv );vec3 position = tmpPos.xyz;vec3 velocity = texture2D( textureVelocity, uv ).xyz;float phase = tmpPos.w;phase = mod( ( phase + delta +length( velocity.xz ) * delta * 3. +max( velocity.y, 0.0 ) * delta * 6. ), 62.83 );gl_FragColor = vec4( position + velocity * delta * 15. , phase );}</script><!-- shader for bird's velocity --><script id="fragmentShaderVelocity" type="x-shader/x-fragment">uniform float time;uniform float testing;uniform float delta; // about 0.016uniform float separationDistance; // 20uniform float alignmentDistance; // 40uniform float cohesionDistance; //uniform float freedomFactor;uniform vec3 predator;const float width = resolution.x;const float height = resolution.y;const float PI = 3.141592653589793;const float PI_2 = PI * 2.0;// const float VISION = PI * 0.55;float zoneRadius = 40.0;float zoneRadiusSquared = 1600.0;float separationThresh = 0.45;float alignmentThresh = 0.65;const float UPPER_BOUNDS = BOUNDS;const float LOWER_BOUNDS = -UPPER_BOUNDS;const float SPEED_LIMIT = 9.0;float rand( vec2 co ){return fract( sin( dot( co.xy, vec2(12.9898,78.233) ) ) * 43758.5453 );}void main() {zoneRadius = separationDistance + alignmentDistance + cohesionDistance;separationThresh = separationDistance / zoneRadius;alignmentThresh = ( separationDistance + alignmentDistance ) / zoneRadius;zoneRadiusSquared = zoneRadius * zoneRadius;vec2 uv = gl_FragCoord.xy / resolution.xy;vec3 birdPosition, birdVelocity;vec3 selfPosition = texture2D( texturePosition, uv ).xyz;vec3 selfVelocity = texture2D( textureVelocity, uv ).xyz;float dist;vec3 dir; // directionfloat distSquared;float separationSquared = separationDistance * separationDistance;float cohesionSquared = cohesionDistance * cohesionDistance;float f;float percent;vec3 velocity = selfVelocity;float limit = SPEED_LIMIT;dir = predator * UPPER_BOUNDS - selfPosition;dir.z = 0.;// dir.z *= 0.6;dist = length( dir );distSquared = dist * dist;float preyRadius = 150.0;float preyRadiusSq = preyRadius * preyRadius;// move birds away from predatorif ( dist < preyRadius ) {f = ( distSquared / preyRadiusSq - 1.0 ) * delta * 100.;velocity += normalize( dir ) * f;limit += 5.0;}// if (testing == 0.0) {}// if ( rand( uv + time ) < freedomFactor ) {}// Attract flocks to the centervec3 central = vec3( 0., 0., 0. );dir = selfPosition - central;dist = length( dir );dir.y *= 2.5;velocity -= normalize( dir ) * delta * 5.;for ( float y = 0.0; y < height; y++ ) {for ( float x = 0.0; x < width; x++ ) {vec2 ref = vec2( x + 0.5, y + 0.5 ) / resolution.xy;birdPosition = texture2D( texturePosition, ref ).xyz;dir = birdPosition - selfPosition;dist = length( dir );if ( dist < 0.0001 ) continue;distSquared = dist * dist;if ( distSquared > zoneRadiusSquared ) continue;percent = distSquared / zoneRadiusSquared;if ( percent < separationThresh ) { // low// Separation - Move apart for comfortf = ( separationThresh / percent - 1.0 ) * delta;velocity -= normalize( dir ) * f;} else if ( percent < alignmentThresh ) { // high// Alignment - fly the same directionfloat threshDelta = alignmentThresh - separationThresh;float adjustedPercent = ( percent - separationThresh ) / threshDelta;birdVelocity = texture2D( textureVelocity, ref ).xyz;f = ( 0.5 - cos( adjustedPercent * PI_2 ) * 0.5 + 0.5 ) * delta;velocity += normalize( birdVelocity ) * f;} else {// Attraction / Cohesion - move closerfloat threshDelta = 1.0 - alignmentThresh;float adjustedPercent;if( threshDelta == 0. ) adjustedPercent = 1.;else adjustedPercent = ( percent - alignmentThresh ) / threshDelta;f = ( 0.5 - ( cos( adjustedPercent * PI_2 ) * -0.5 + 0.5 ) ) * delta;velocity += normalize( dir ) * f;}}}// this make tends to fly around than down or up// if (velocity.y > 0.) velocity.y *= (1. - 0.2 * delta);// Speed Limitsif ( length( velocity ) > limit ) {velocity = normalize( velocity ) * limit;}gl_FragColor = vec4( velocity, 1.0 );}</script><script type="x-shader/x-vertex" id="birdVS">attribute vec2 reference;attribute float birdVertex;attribute vec3 birdColor;uniform sampler2D texturePosition;uniform sampler2D textureVelocity;varying vec4 vColor;varying float z;uniform float time;void main() {vec4 tmpPos = texture2D( texturePosition, reference );vec3 pos = tmpPos.xyz;vec3 velocity = normalize(texture2D( textureVelocity, reference ).xyz);vec3 newPosition = position;if ( birdVertex == 4.0 || birdVertex == 7.0 ) {// flap wingsnewPosition.y = sin( tmpPos.w ) * 5.;}newPosition = mat3( modelMatrix ) * newPosition;float xz = length( velocity.xz );float xyz = 1.;float x = sqrt( 1. - velocity.y * velocity.y );float cosry = velocity.z / xz;float sinry = velocity.x / xz;float cosrx = x / xyz;float sinrx = -velocity.y / xyz;mat3 maty =  mat3(cosry, 0, -sinry,0    , 1, 0     ,sinry, 0, cosry);mat3 matX = mat3(1, 0, 0,0, cosrx, sinrx,0, -sinrx, cosrx );newPosition =  maty * matX * newPosition;newPosition += pos;z = newPosition.z;vColor = vec4( birdColor, 1.0 );gl_Position = projectionMatrix *  viewMatrix  * vec4( newPosition, 1.0 );}</script><!-- bird geometry shader --><script type="x-shader/x-fragment" id="birdFS">varying vec4 vColor;varying float z;uniform vec3 color;void main() {// Fake colors for nowfloat z2 = 0.2 + ( 1000. - z ) / 1000. * vColor.x;gl_FragColor = vec4( z2, z2, z2, 1. );}</script><!-- Import maps polyfill --><!-- Remove this when import maps will be widely supported --><script async src="@1.3.6/dist/es-module-shims.js"></script><script type="importmap">{"imports": {"three": "../build/three.module.js","three/addons/": "./jsm/"}}</script><script type="module">import * as THREE from 'three';import Stats from 'three/addons/libs/stats.module.js';import { GUI } from 'three/addons/libs/lil-gui.module.min.js';import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js';/* TEXTURE WIDTH FOR SIMULATION */const WIDTH = 32;const BIRDS = WIDTH * WIDTH;// Custom Geometry - using 3 triangles each. No UVs, no normals currently.class BirdGeometry extends THREE.BufferGeometry {constructor() {super();const trianglesPerBird = 3;const triangles = BIRDS * trianglesPerBird;const points = triangles * 3;const vertices = new THREE.BufferAttribute( new Float32Array( points * 3 ), 3 );const birdColors = new THREE.BufferAttribute( new Float32Array( points * 3 ), 3 );const references = new THREE.BufferAttribute( new Float32Array( points * 2 ), 2 );const birdVertex = new THREE.BufferAttribute( new Float32Array( points ), 1 );this.setAttribute( 'position', vertices );this.setAttribute( 'birdColor', birdColors );this.setAttribute( 'reference', references );this.setAttribute( 'birdVertex', birdVertex );// this.setAttribute( 'normal', new Float32Array( points * 3 ), 3 );let v = 0;function verts_push() {for ( let i = 0; i < arguments.length; i ++ ) {vertices.array[ v ++ ] = arguments[ i ];}}const wingsSpan = 20;for ( let f = 0; f < BIRDS; f ++ ) {// Bodyverts_push(0, - 0, - 20,0, 4, - 20,0, 0, 30);// Wingsverts_push(0, 0, - 15,- wingsSpan, 0, 0,0, 0, 15);verts_push(0, 0, 15,wingsSpan, 0, 0,0, 0, - 15);}for ( let v = 0; v < triangles * 3; v ++ ) {const triangleIndex = ~ ~ ( v / 3 );const birdIndex = ~ ~ ( triangleIndex / trianglesPerBird );const x = ( birdIndex % WIDTH ) / WIDTH;const y = ~ ~ ( birdIndex / WIDTH ) / WIDTH;const c = new THREE.Color(0x444444 +~ ~ ( v / 9 ) / BIRDS * 0x666666);birdColors.array[ v * 3 + 0 ] = c.r;birdColors.array[ v * 3 + 1 ] = c.g;birdColors.array[ v * 3 + 2 ] = c.b;references.array[ v * 2 ] = x;references.array[ v * 2 + 1 ] = y;birdVertex.array[ v ] = v % 9;}this.scale( 0.2, 0.2, 0.2 );}}//let container, stats;let camera, scene, renderer;let mouseX = 0, mouseY = 0;let windowHalfX = window.innerWidth / 2;let windowHalfY = window.innerHeight / 2;const BOUNDS = 800, BOUNDS_HALF = BOUNDS / 2;let last = performance.now();let gpuCompute;let velocityVariable;let positionVariable;let positionUniforms;let velocityUniforms;let birdUniforms;init();animate();function init() {container = document.createElement( 'div' );document.body.appendChild( container );camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 1, 3000 );camera.position.z = 350;scene = new THREE.Scene();scene.background = new THREE.Color( 0xffffff );scene.fog = new THREE.Fog( 0xffffff, 100, 1000 );renderer = new THREE.WebGLRenderer();renderer.setPixelRatio( window.devicePixelRatio );renderer.setSize( window.innerWidth, window.innerHeight );container.appendChild( renderer.domElement );initComputeRenderer();stats = new Stats();container.appendChild( stats.dom );container.style.touchAction = 'none';container.addEventListener( 'pointermove', onPointerMove );//window.addEventListener( 'resize', onWindowResize );const gui = new GUI();const effectController = {separation: 20.0,alignment: 20.0,cohesion: 20.0,freedom: 0.75};const valuesChanger = function () {velocityUniforms[ 'separationDistance' ].value = effectController.separation;velocityUniforms[ 'alignmentDistance' ].value = effectController.alignment;velocityUniforms[ 'cohesionDistance' ].value = effectController.cohesion;velocityUniforms[ 'freedomFactor' ].value = effectController.freedom;};valuesChanger();gui.add( effectController, 'separation', 0.0, 100.0, 1.0 ).onChange( valuesChanger );gui.add( effectController, 'alignment', 0.0, 100, 0.001 ).onChange( valuesChanger );gui.add( effectController, 'cohesion', 0.0, 100, 0.025 ).onChange( valuesChanger );gui.close();initBirds();}function initComputeRenderer() {gpuCompute = new GPUComputationRenderer( WIDTH, WIDTH, renderer );if ( renderer.capabilities.isWebGL2 === false ) {gpuCompute.setDataType( THREE.HalfFloatType );}const dtPosition = gpuCompute.createTexture();const dtVelocity = gpuCompute.createTexture();fillPositionTexture( dtPosition );fillVelocityTexture( dtVelocity );velocityVariable = gpuCompute.addVariable( 'textureVelocity', document.getElementById( 'fragmentShaderVelocity' ).textContent, dtVelocity );positionVariable = gpuCompute.addVariable( 'texturePosition', document.getElementById( 'fragmentShaderPosition' ).textContent, dtPosition );gpuCompute.setVariableDependencies( velocityVariable, [ positionVariable, velocityVariable ] );gpuCompute.setVariableDependencies( positionVariable, [ positionVariable, velocityVariable ] );positionUniforms = positionVariable.material.uniforms;velocityUniforms = velocityVariable.material.uniforms;positionUniforms[ 'time' ] = { value: 0.0 };positionUniforms[ 'delta' ] = { value: 0.0 };velocityUniforms[ 'time' ] = { value: 1.0 };velocityUniforms[ 'delta' ] = { value: 0.0 };velocityUniforms[ 'testing' ] = { value: 1.0 };velocityUniforms[ 'separationDistance' ] = { value: 1.0 };velocityUniforms[ 'alignmentDistance' ] = { value: 1.0 };velocityUniforms[ 'cohesionDistance' ] = { value: 1.0 };velocityUniforms[ 'freedomFactor' ] = { value: 1.0 };velocityUniforms[ 'predator' ] = { value: new THREE.Vector3() };velocityVariable.material.defines.BOUNDS = BOUNDS.toFixed( 2 );velocityVariable.wrapS = THREE.RepeatWrapping;velocityVariable.wrapT = THREE.RepeatWrapping;positionVariable.wrapS = THREE.RepeatWrapping;positionVariable.wrapT = THREE.RepeatWrapping;const error = gpuCompute.init();if ( error !== null ) {console.error( error );}}function initBirds() {const geometry = new BirdGeometry();// For Vertex and FragmentbirdUniforms = {'color': { value: new THREE.Color( 0xff2200 ) },'texturePosition': { value: null },'textureVelocity': { value: null },'time': { value: 1.0 },'delta': { value: 0.0 }};// THREE.ShaderMaterialconst material = new THREE.ShaderMaterial( {uniforms: birdUniforms,vertexShader: document.getElementById( 'birdVS' ).textContent,fragmentShader: document.getElementById( 'birdFS' ).textContent,side: THREE.DoubleSide} );const birdMesh = new THREE.Mesh( geometry, material );birdMesh.matrixAutoUpdate = false;birdMesh.updateMatrix();scene.add( birdMesh );}function fillPositionTexture( texture ) {const theArray = texture.image.data;for ( let k = 0, kl = theArray.length; k < kl; k += 4 ) {const x = Math.random() * BOUNDS - BOUNDS_HALF;const y = Math.random() * BOUNDS - BOUNDS_HALF;const z = Math.random() * BOUNDS - BOUNDS_HALF;theArray[ k + 0 ] = x;theArray[ k + 1 ] = y;theArray[ k + 2 ] = z;theArray[ k + 3 ] = 1;}}function fillVelocityTexture( texture ) {const theArray = texture.image.data;for ( let k = 0, kl = theArray.length; k < kl; k += 4 ) {const x = Math.random() - 0.5;const y = Math.random() - 0.5;const z = Math.random() - 0.5;theArray[ k + 0 ] = x * 10;theArray[ k + 1 ] = y * 10;theArray[ k + 2 ] = z * 10;theArray[ k + 3 ] = 1;}}function onWindowResize() {windowHalfX = window.innerWidth / 2;windowHalfY = window.innerHeight / 2;camera.aspect = window.innerWidth / window.innerHeight;camera.updateProjectionMatrix();renderer.setSize( window.innerWidth, window.innerHeight );}function onPointerMove( event ) {if ( event.isPrimary === false ) return;mouseX = event.clientX - windowHalfX;mouseY = event.clientY - windowHalfY;}//function animate() {requestAnimationFrame( animate );render();stats.update();}function render() {const now = performance.now();let delta = ( now - last ) / 1000;if ( delta > 1 ) delta = 1; // safety cap on large deltaslast = now;positionUniforms[ 'time' ].value = now;positionUniforms[ 'delta' ].value = delta;velocityUniforms[ 'time' ].value = now;velocityUniforms[ 'delta' ].value = delta;birdUniforms[ 'time' ].value = now;birdUniforms[ 'delta' ].value = delta;velocityUniforms[ 'predator' ].value.set( 0.5 * mouseX / windowHalfX, - 0.5 * mouseY / windowHalfY, 0 );mouseX = 10000;mouseY = 10000;gpuCompute.compute();birdUniforms[ 'texturePosition' ].value = gpuCompute.getCurrentRenderTarget( positionVariable ).texture;birdUniforms[ 'textureVelocity' ].value = gpuCompute.getCurrentRenderTarget( velocityVariable ).texture;renderer.render( scene, camera );}</script></body>
</html>

下面代码是为研究朝向问题,写的一个小例子,TestBirdRotate.html

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>bird rotate</title><style>body {margin: 0;padding: 0;overflow: hidden;}</style><script src="../lib/dat.gui.min.js"></script>
</head>
<body>
<script type="module">import * as THREE from '../lib/three.module.js'import {OrbitControls} from "../lib/jsm/OrbitControls.js"const champagne = '#fff9b1'const creamy_white = '#f0ebd5'let camera, scene, renderer;let cameraControlslet screenconst raycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3());let birdRotating = false;let trackMeshconst iBirdMesh = new THREE.Group()const sphere = new THREE.Sphere(new THREE.Vector3(0,0,0), 1)const sphereIntersect = new THREE.Vector3()const r2d = 180 / Math.PI;const pi = Math.PI;let objRx, objRy, objRzinit()animate()function init() {const width = window.innerWidthconst height = window.innerHeightscreen = {left: 0,top: 0,width: width,height: height}camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 10 )camera.position.set(-2, 1.5, 2.5)scene = new THREE.Scene()scene.add(new THREE.AxesHelper(2000))scene.background = new THREE.Color( 0, 0, 0 )renderer = new THREE.WebGLRenderer({antialias : true})renderer.setSize( window.innerWidth, window.innerHeight )document.body.appendChild( renderer.domElement )cameraControls = new OrbitControls(camera, renderer.domElement);cameraControls.minDistance = 1;cameraControls.maxDistance = 100;window.addEventListener( 'resize', onWindowResize );initMeshes();raycaster.camera = camera;initListeners();setUpGui();}function initMeshes() {const wingsSpan = 20;const vertices = [// body0, - 0, - 20,0, 4, - 20,0, 0, 30,// wings0, 0, - 15,- wingsSpan, 0, 0,0, 0, 15,0, 0, 15,wingsSpan, 0, 0,0, 0, -15,// ray test line// 0,0,0,// 0,0.1,0,// 0,0,120,];const birdGeometry = new THREE.BufferGeometry();birdGeometry.addAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));const birdMtl = new THREE.MeshBasicMaterial({color: champagne, wireframe: true});const birdMesh = new THREE.Mesh(birdGeometry, birdMtl);scene.add(iBirdMesh);iBirdMesh.add(birdMesh);birdMesh.scale.set(0.022, 0.022, 0.022);// birdMesh.rotation.y = Math.PI / 2;const ballGeo = new THREE.SphereGeometry(1);const ballMtl = new THREE.MeshBasicMaterial({color: '#02230a', wireframe: true})const ballMesh = new THREE.Mesh(ballGeo, ballMtl);scene.add(ballMesh)const ballGeo2 = new THREE.SphereGeometry(0.03, 2, 2)const ballMtl2 = new THREE.MeshBasicMaterial({color: '#b0d', wireframe: true})trackMesh = new THREE.Mesh(ballGeo2, ballMtl2);}function initListeners() {document.addEventListener('mouseup', () => {});document.addEventListener('mousemove', (event) => {if (!birdRotating) {return}updateRaycaster(event.clientX, event.clientY);raycaster.ray.intersectSphere(sphere, sphereIntersect)trackMesh.position.copy(sphereIntersect)// console.log("ball intersects: ", sphereIntersect)// 拷贝来源 THREE.Spherical.setFromCartesianCoords( x, y, z )// 方位角θ 是在xz平面 目标法向量与 (0,0,1)的夹角,如果目标法向为(0,0,1)则为 90°,如果目标法向为(0,0,-1)则为 -90°,// 俯仰角φ 目标法向量与 (0,1,0)的夹角,如果目标法向接近(0,1,0)则俯仰角φ 接近 0,如果目标法向接近(0,-1,0),则俯仰角φ 接近 180°const {x, y, z} = sphereIntersectconst theta = Math.atan2(x, z)const thetaDeg = theta * r2dconst phi = Math.acos( THREE.MathUtils.clamp( y , - 1, 1 ) ) - Math.PI * 0.5const phiDeg = phi * r2d// console.log("thetaDeg: " + thetaDeg + ", phiDeg: " + phiDeg)// iBirdMesh.rotation.y = theta// iBirdMesh.children[0].rotation.x = phi// -------------------------------------------// iBirdMesh.children[0].rotation.y = theta// iBirdMesh.rotation.x = phiconst matX = new THREE.Matrix4().makeRotationX(phi)const matY = new THREE.Matrix4().makeRotationY(theta)iBirdMesh.children[0].quaternion.set(0, 0, 0, 1)const mat = matY.multiply(matX)iBirdMesh.children[0].applyMatrix4(mat)// iBirdMesh.children[0].applyMatrix4(matX)// iBirdMesh.children[0].applyMatrix4(matY)const rot = iBirdMesh.children[0].rotationobjRx.setValue(rot.x)objRy.setValue(rot.y)objRz.setValue(rot.z)// iBirdMesh.children[0].quaternion.set(0, 0, 0, 1)// iBirdMesh.children[0].rotateOnAxis(new THREE.Vector3(0,1,0), theta)// iBirdMesh.children[0].rotateOnAxis(new THREE.Vector3(1,0,0), phi)});}function updateRaycaster(x, y) {const vector = new THREE.Vector3(( ( x - screen.left ) / screen.width ) * 2 - 1, -( ( y - screen.top ) / screen.height ) * 2 + 1, 0.5);vector.unproject(camera);raycaster.ray.origin.copy(camera.position);raycaster.ray.direction.copy(vector).sub(camera.position).normalize();}function setUpGui() {const guiData = {rx: 0,ry: 0,rz: 0,followMouse: birdRotating,resetCamera: function () {cameraControls.reset()},resetRotation: function () {const birdMesh = iBirdMesh.children[0]birdMesh.rotation.set(0, 0, 0)iBirdMesh.rotation.set(0, 0, 0)// guiData.rx = guiData.ry = guiData.rz = 0objRx.setValue(0)objRy.setValue(0)objRz.setValue(0)},}const gui = new dat.GUI();objRx = gui.add(guiData, 'rx', -pi, pi, 0.01).onChange(function (e) {iBirdMesh.children[0].rotation.x = e});objRy = gui.add(guiData, 'ry', -pi, pi, 0.01).onChange(function (e) {iBirdMesh.children[0].rotation.y = e});objRz = gui.add(guiData, 'rz', -pi, pi, 0.01).onChange(function (e) {iBirdMesh.children[0].rotation.z = e});gui.add(guiData, 'followMouse').name('跟随鼠标').onChange(function (e) {birdRotating = eif (!trackMesh.parent) {scene.add(trackMesh)}});gui.add(guiData, 'resetCamera').name("重置相机");gui.add(guiData, 'resetRotation').name("重置旋转");}function onWindowResize() {camera.aspect = window.innerWidth / window.innerHeight;camera.updateProjectionMatrix();renderer.setSize( window.innerWidth, window.innerHeight );}function animate() {cameraControls.update();renderer.render( scene, camera );requestAnimationFrame( animate );}
</script>
</body>
</html>
发布评论

评论列表 (0)

  1. 暂无评论