跳转至

高斯泼溅渲染系统

oryzo.ai 一切照片感的地基:three.js 之上自研的 3D Gaussian Splatting 渲染器。 这一篇把整套系统的三个关键部件放在一起讲 —— 光栅化管线、异步排序、假反射 —— 三个 demo 全部按公开算法(Kerbl et al. 2023)从零实现,管线彼此同源。

光栅化管线

约 5,700 个程序化高斯拼出"杯垫 + 切割垫",拖拽旋转;勾选右上角能看到每个 splat 的包围四边形,泼溅渲染的 overdraw 代价一目了然。

  • 一个高斯 = 中心 + 各向异性缩放 + 旋转四元数 + 颜色 + 不透明度;3D 协方差 Σ=(RS)(RS)ᵀ
  • 顶点着色器用透视雅可比把 Σ 投成屏幕 2×2 协方差,特征分解出椭圆长短轴撑开 quad(3σ),一次 instanced draw 画全部
  • 片元用 conic(协方差之逆)算 exp(-½ dᵀΣ′⁻¹d) 衰减;对角 +0.3px² 当最小 footprint 兼抗锯齿
  • 近平面剔除是保命:高斯贴近相机时 focal/z 爆炸,一个高斯就能糊满全屏 —— 复刻时实测中招
  • 原站的照片感在数据里不在渲染器里:它的杯垫是训练出来的扫描资产 —— 对实物多视角拍摄,用 3DGS 优化管线让几十万个高斯的位置/形状/颜色/球谐被梯度下降摆到"渲染 = 照片"。渲染器(本文复刻的这套)只负责画
  • 程序化没法训练,但可以逼近训练资产的三个性质(demo 两版翻车换来的):① 颜色必须来自同一张纹理按位置采样(离屏 canvas 画软木颗粒 + 气孔),每粒各自随机 = 泥;② 粒子要贴在光滑车削曲面上黄金角螺旋均匀采样,σ 咬住间距(0.85×spacing)——随机撒点的轮廓是花菜;③ 光照要有方向性:每粒带法线做 N·L + 分区 AO + 接触阴影 —— 训练资产的光照烤在球谐里,程序化用这三样近似
  • 原站的工程化:属性量化打包进整数纹理(texelFetch + 位运算解包),颜色是球谐按视角求值
  • 原站资产 .sog = 免压缩 ZIP 装 meta.json + 一组 webp(均值/缩放/旋转/球谐各一张图),createImageBitmap 直送纹理 —— 借浏览器图片解码器给量化数据做解压;量化细节:均值对数编码、四元数最小三分量 + 2bit 模式位、缩放 log 插值、球谐 11-11-10 打包

异步深度排序

半透明高斯必须从远到近画,相机一动就得重排全场。实验台里三团穿插的半透明球当"照妖镜",三种模式 + 排序耗时滑杆,各架构的代价当场现形。

  • 三种模式:Worker 异步 = 帧率稳但顺序晚 N 帧到货(穿插处"游一下",即 splat swimming);主线程同步 = 顺序新鲜但 FPS 跳水;不排序 = 前后关系胡说八道
  • oryzo 用 Rust→wasm 的 SplatSorter(38.5KB)跑在 Worker 里:positions/indices 直接开在 wasm 线性内存(零拷贝),近平面剔除也在排序阶段做掉(返回 validCount)
  • 消息协议(demo 照形状复刻):INIT{positions} 一次常驻 → READY;每单 SORT{视线方向, 排序缓冲}SORT_DONE{排序缓冲},索引缓冲 transferable 往返 ping-pong,零克隆零 GC
  • 一次只挂一单:排序在途不发新请求,新相机角度并进下一单 —— 排序幂等,丢中间帧无害
  • 原站排序结果写进"索引纹理"由顶点着色器间接寻址;demo 简化为重排实例缓冲

镜像假反射

桌面那层产品摄影式的反光不是实时反射:原站加载第二份镜像泼溅资产 table_reflection.sog(0.5MB),opacity 0.5 垫底画 —— 用渲染顺序换掉一整套屏幕空间反射。

  • 三件套:镜像变换 + 全局透明度 + 先画垫底;没有反射探针、没有 SSR
  • 镜像不只翻中心:协方差跟着 Σ′=FΣFᵀ 变(F=diag(1,1,-1)),代码里就是把 M=R·S 第三行取反
  • 相机限制在桌面以上 → "倒影整体先画"永远是正确遮挡序,两个 pass 各自内部排序
  • demo 加料(原站无):倒影随离面深度指数衰减,开关可对比;桌面交给 CSS 渐变,canvas 开 premultipliedAlpha:false 直接合成

源码(折叠)

gaussian-splat-demo.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>迷你高斯泼溅 · ORYZO 核心渲染管线教学复刻</title>
<style>
  /* 配色取自 oryzo.ai:橄榄绿切割垫 + 软木暖棕 + 米白文字 + 蓝图橙 */
  :root {
    --bg: #3f4a32;
    --ink: #f2e8d5;
    --accent: #f08c2e;
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; overflow: hidden; background: var(--bg); }
  canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
  canvas:active { cursor: grabbing; }
  .hud {
    position: fixed; left: 16px; top: 14px; color: var(--ink);
    font: 12px/1.7 "DM Mono", ui-monospace, monospace; letter-spacing: .06em;
    pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.4);
  }
  .hud b { font-size: 14px; letter-spacing: .12em; }
  .hint {
    position: fixed; left: 16px; bottom: 14px; color: var(--ink); opacity: .75;
    font: 11px/1.6 "DM Mono", ui-monospace, monospace; letter-spacing: .06em;
    pointer-events: none;
  }
  label.quads {
    position: fixed; right: 16px; top: 14px; color: var(--ink);
    font: 12px/1.6 "DM Mono", ui-monospace, monospace; letter-spacing: .05em;
    user-select: none; cursor: pointer; display: flex; gap: 6px; align-items: center;
  }
  label.quads input { accent-color: var(--accent); }
  .err {
    position: fixed; inset: 0; display: none; place-items: center; color: var(--ink);
    font: 14px/1.8 system-ui; padding: 2em; text-align: center;
  }
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div class="hud"><b>GAUSSIAN SPLATTING</b><br><span id="stat"></span></div>
<label class="quads"><input type="checkbox" id="showQuads"> 显示每个 splat 的包围四边形</label>
<div class="hint">拖拽旋转 · 每帧 CPU 按视深排序后重传实例缓冲(oryzo 用 Rust→wasm 排序器在 Worker 里做同一件事)</div>
<div class="err" id="err">需要 WebGL2 支持才能运行这个 demo。</div>

<script>
/* ============================================================================
 * 迷你高斯泼溅(3D Gaussian Splatting)—— ORYZO 官网核心渲染路线的教学版
 *
 * 场景致敬 oryzo.ai 的 hero:一枚软木杯垫摆在橄榄绿切割垫上。
 * 原站加载的是 SOG 压缩的真实扫描资产(约 3.6MB 两份 .sog);
 * 这里为了自包含,用程序化生成的 ~1400 个各向异性高斯代替。
 *
 * 管线与原站一致(算法出自 Kerbl et al. 2023《3D Gaussian Splatting
 * for Real-Time Radiance Field Rendering》,属公开发表的方法):
 *   1. 每个高斯 = 中心 μ + 缩放 S + 旋转 R(四元数) + 颜色 + 不透明度
 *   2. 3D 协方差 Σ = (RS)(RS)^T
 *   3. 顶点着色器用透视投影的雅可比 J 把 Σ 投到屏幕:Σ' = J W Σ W^T J^T
 *   4. 对 2×2 的 Σ' 做特征分解 → 两条主轴 → 把 4 顶点 quad 撑成贴屏椭圆
 *   5. 片元用 conic(Σ'⁻¹)算高斯衰减 exp(-½ dᵀΣ'⁻¹d),半透明混合
 *   6. 混合正确性靠"从远到近"排序 —— 本 demo 每帧在 CPU 上 argsort
 *      后重传实例缓冲;oryzo 则把索引写进纹理,由 wasm 排序器异步产出
 *
 * 与原站的刻意差异(教学取舍):
 *   - 属性走 per-instance attribute(易读);原站全部打包进整数纹理,
 *     顶点着色器里 texelFetch + 手写 unpack(省显存带宽,配合 SOG 量化)
 *   - 颜色只用常数色;原站是球谐系数(SH_C0..C3)按视角方向求值
 *   - 排序在主线程;原站在 Worker + Rust wasm,结果 transferable 回传
 * ========================================================================== */

const canvas = document.getElementById('gl');
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false });
if (!gl) { document.getElementById('err').style.display = 'grid'; throw new Error('no webgl2'); }

/* ---------- 1. 程序化生成"软木杯垫 + 切割垫"的高斯云 ---------- */

const splats = [];   // {center:[3], scale:[3], quat:[4], color:[3], alpha}
const rand = (a, b) => a + Math.random() * (b - a);

// 从"z 轴对齐到法线 n"构造四元数(x,y,z,w)——扁平 splat 贴着表面长
function quatFromNormal(n) {
  const z = [0, 0, 1];
  const d = z[0]*n[0] + z[1]*n[1] + z[2]*n[2];
  if (d > 0.9999) return [0, 0, 0, 1];
  if (d < -0.9999) return [1, 0, 0, 0];          // 180°:绕 x 轴翻
  const ax = [z[1]*n[2]-z[2]*n[1], z[2]*n[0]-z[0]*n[2], z[0]*n[1]-z[1]*n[0]];
  const l = Math.hypot(...ax), a = Math.acos(d);
  const s = Math.sin(a/2) / l;
  return [ax[0]*s, ax[1]*s, ax[2]*s, Math.cos(a/2)];
}

/* —— 为什么这样造数据 ——
 * 原站的杯垫是"训练出来的资产":对实物多视角拍摄后用 3DGS 优化管线
 * 摆好几十万个高斯,照片感全在数据里。程序化没法训练,但可以逼近
 * 训练资产的三个性质:① 颜色有空间相关性(来自同一张纹理,而不是
 * 每粒各自随机);② 粒子贴在光滑曲面上、分布均匀(车削轮廓 + 黄金角
 * 螺旋采样,轮廓才干净);③ 光照是方向性的(法线 N·L + 分区 AO +
 * 接触阴影)。三者缺一,就会退化成"随机噪声堆"。 */

// 软木纹理:离屏 canvas 画一张(颗粒团聚 + 深色气孔 + 低频起伏),按位置采样
function makeCorkSampler() {
  const S = 512, c = document.createElement('canvas');
  c.width = c.height = S;
  const g = c.getContext('2d');
  g.fillStyle = '#c99a5f'; g.fillRect(0, 0, S, S);
  const shades = ['#e0b67c', '#c78f54', '#b67e45', '#d8ab70', '#a8703a', '#d1a066', '#bd8a50', '#e6c08a'];
  for (let i = 0; i < 3200; i++) {           // 软木碎粒压合的团聚感
    g.fillStyle = shades[Math.random() * shades.length | 0];
    g.globalAlpha = rand(0.35, 0.85);
    g.beginPath();
    g.ellipse(Math.random() * S, Math.random() * S, rand(2, 8), rand(1.5, 5), Math.random() * Math.PI, 0, 7);
    g.fill();
  }
  g.globalAlpha = 0.06;                       // 低频明暗起伏
  for (let i = 0; i < 46; i++) {
    g.fillStyle = Math.random() < 0.5 ? '#8a5c2e' : '#eec48d';
    g.beginPath();
    g.ellipse(Math.random() * S, Math.random() * S, rand(40, 110), rand(30, 90), Math.random() * Math.PI, 0, 7);
    g.fill();
  }
  g.globalAlpha = 1;
  for (let i = 0; i < 320; i++) {             // 深色气孔(小而稀)
    g.fillStyle = '#4a2f18';
    g.globalAlpha = rand(0.5, 0.85);
    g.beginPath();
    g.ellipse(Math.random() * S, Math.random() * S, rand(0.8, 2.4), rand(0.8, 2), Math.random() * Math.PI, 0, 7);
    g.fill();
  }
  const d = g.getImageData(0, 0, S, S).data;
  return (x, y) => {                          // 世界坐标平面映射(0.48 保证不越界回卷)
    const u = Math.min(S - 1, Math.max(0, (x * 0.48 + 0.5) * S | 0));
    const v = Math.min(S - 1, Math.max(0, (y * 0.48 + 0.5) * S | 0));
    const i = (v * S + u) * 4;
    return [d[i] / 255, d[i + 1] / 255, d[i + 2] / 255];
  };
}
const corkTex = makeCorkSampler();
const GA = Math.PI * (3 - Math.sqrt(5));      // 黄金角:螺旋采样零聚团

// 杯垫 = 车削轮廓(凹面 / 内坡 / 平沿 / 圆唇 / 外壁),每段独立均匀采样。
// spacing = sqrt(段面积/N),σ = 0.85×spacing —— σ 咬住间距,既无缝也不糊
function revolve({ N, pos, nrm, ao }) {
  for (let k = 0; k < N; k++) {
    const t = (k + 0.5) / N, th = k * GA;
    const [r, z] = pos(t), n2 = nrm(t);
    const cx = r * Math.cos(th), cy = r * Math.sin(th);
    const n = [n2[0] * Math.cos(th), n2[0] * Math.sin(th), n2[1]];
    const col = corkTex(cx, cy), shade = ao(t) * rand(0.97, 1.03);
    const s = 0.0135 * rand(0.85, 1.15);
    splats.push({
      center: [cx, cy, z],
      scale: [s, s, 0.005],
      quat: quatFromNormal(n),
      color: [col[0] * shade, col[1] * shade, col[2] * shade],
      alpha: 1,
      normal: n,
    });
  }
}
// 凹陷的内盘面(r 0→0.74,sqrt 均匀)
revolve({ N: 6800, pos: t => [0.74 * Math.sqrt(t), 0.10], nrm: () => [0, 1], ao: () => 0.88 });
// 内坡(面 → 沿)
revolve({ N: 1700, pos: t => [0.74 + t * 0.08, 0.10 + t * 0.055], nrm: () => [-0.566, 0.824], ao: t => 0.8 + t * 0.16 });
// 平沿(环带)
revolve({ N: 2600, pos: t => [Math.sqrt(0.82 ** 2 + t * (0.94 ** 2 - 0.82 ** 2)), 0.155], nrm: () => [0, 1], ao: () => 1.0 });
// 圆唇(四分之一圆弧过渡到外壁)
revolve({ N: 1900, pos: t => { const a = t * Math.PI / 2; return [0.94 + 0.05 * Math.sin(a), 0.105 + 0.05 * Math.cos(a)]; },
          nrm: t => { const a = t * Math.PI / 2; return [Math.sin(a), Math.cos(a)]; }, ao: t => 0.98 - t * 0.1 });
// 外壁(垂直)
revolve({ N: 2100, pos: t => [0.99, 0.105 - t * 0.09], nrm: () => [1, 0], ao: t => 0.86 - t * 0.18 });

// 接触阴影:垫子上一摊柔和的暗斑把杯垫"放"到地上(略朝光的反方向偏)
{
  for (let i = 0; i < 170; i++) {
    const th = Math.random() * Math.PI * 2, rr = 1.12 * Math.sqrt(Math.random());
    const s = rand(0.08, 0.16);
    splats.push({
      center: [rr * Math.cos(th) - 0.10, rr * Math.sin(th) + 0.07, -0.015],
      scale: [s, s, 0.01],
      quat: quatFromNormal([0, 0, 1]),
      color: [0.16, 0.20, 0.12],
      alpha: 0.4,
      normal: [0, 0, 1],
    });
  }
}

// ③ 切割垫:扁平深绿高斯铺地。刻意用"小而密"而不是"大而稀"——
//    大 σ 的高斯在斜视/近景时会被雅可比摊成几百像素的低 alpha 长条,
//    几十片叠起来就是一层洗掉前景的雾(真实 3DGS 场景靠百万级小高斯避开这坑)
{
  const N = 2400;
  for (let i = 0; i < N; i++) {
    const t = Math.random() * Math.PI * 2;
    const rr = 1.02 + 1.7 * Math.pow(Math.random(), 0.7);   // 半径压在相机水平距离以内,防止 splat 跑到镜头跟前
    const g = rand(-0.025, 0.025);
    splats.push({
      center: [rr * Math.cos(t), rr * Math.sin(t), -0.02],
      scale: [rand(0.05, 0.1), rand(0.05, 0.1), 0.012],
      quat: quatFromNormal([0, 0, 1]),
      color: [0.27 + g, 0.33 + g, 0.20 + g],
      alpha: rand(0.95, 1),
      normal: [0, 0, 1],
    });
  }
  // 垫子中央(杯垫正下方)补一层,防止缝隙漏出背景色
  for (let i = 0; i < 300; i++) {
    const t = Math.random() * Math.PI * 2, rr = Math.sqrt(Math.random()) * 1.08;
    splats.push({
      center: [rr * Math.cos(t), rr * Math.sin(t), -0.03],
      scale: [rand(0.06, 0.11), rand(0.06, 0.11), 0.012],
      quat: quatFromNormal([0, 0, 1]),
      color: [0.25, 0.31, 0.19],
      alpha: 1,
      normal: [0, 0, 1],
    });
  }
}

const COUNT = splats.length;
document.getElementById('stat').textContent =
  COUNT.toLocaleString() + ' 个各向异性高斯 · 程序化生成 · WebGL2 实例化';

/* ---------- 2. 着色器:3DGS 光栅化的最小完整实现 ---------- */

const VS = `#version 300 es
precision highp float;
// 每实例一份的高斯参数(oryzo 用整数纹理 texelFetch + 位运算 unpack 取同样的数据)
in vec3 iCenter;
in vec3 iScale;
in vec4 iQuat;      // (x,y,z,w)
in vec4 iColorA;    // rgb + alpha
in vec3 iNormal;    // 表面法线:方向光着色用(训练资产靠球谐,这里用 N·L 近似)

uniform mat4 uView;
uniform mat4 uProj;
uniform vec2 uViewport;   // 物理像素
uniform vec2 uFocal;      // 投影焦距(像素)
uniform vec3 uLight;      // 世界空间方向光(已归一化)

out vec4 vColor;
out vec3 vConic;    // 2D 协方差的逆(conic):x=A, y=B, z=C
out vec2 vPixPos;   // 当前顶点相对椭圆中心的像素偏移
out vec2 vCorner;   // quad 角坐标 ∈ [-1,1],画边界用
flat out float vQuad;

uniform float uShowQuads;

mat3 quatToMat3(vec4 q) {
  float x=q.x, y=q.y, z=q.z, w=q.w;
  return mat3(
    1.-2.*(y*y+z*z), 2.*(x*y+w*z),   2.*(x*z-w*y),
    2.*(x*y-w*z),    1.-2.*(x*x+z*z),2.*(y*z+w*x),
    2.*(x*z+w*y),    2.*(y*z-w*x),   1.-2.*(x*x+y*y));
}

void main() {
  // 4 顶点 TRIANGLE_STRIP 摊出一个 quad;oryzo 用 gl_VertexID/4 一次画完全部
  vec2 corner = vec2(float(gl_VertexID % 2), float(gl_VertexID / 2)) * 2. - 1.;
  vCorner = corner;
  vQuad = uShowQuads;

  vec4 viewC = uView * vec4(iCenter, 1.);
  // 近平面剔除:splat 贴近相机时雅可比 ~focal/z 会爆炸,把单个高斯摊成全屏雾,
  // 必须剔掉。oryzo 把 nearPlane 传进 wasm 排序器,由排序阶段吐出 validCount 完成同一件事
  if (viewC.z > -0.3) { gl_Position = vec4(0., 0., 2., 1.); return; }

  // Σ = (R·S)(R·S)^T —— 各向异性 3D 协方差
  mat3 R = quatToMat3(iQuat);
  mat3 M = R * mat3(iScale.x,0.,0., 0.,iScale.y,0., 0.,0.,iScale.z);
  mat3 Sigma = M * transpose(M);

  // 透视投影在 μ 处的一阶泰勒(雅可比):EWA splatting 的核心一步
  float invZ = 1. / viewC.z;
  float invZ2 = invZ * invZ;
  mat3 J = mat3(
    uFocal.x * invZ, 0.,              0.,
    0.,              uFocal.y * invZ, 0.,
    -uFocal.x * viewC.x * invZ2, -uFocal.y * viewC.y * invZ2, 0.);
  mat3 W = mat3(uView);
  mat3 T = J * W;
  mat3 Sigma2 = T * Sigma * transpose(T);

  // 取左上 2×2,对角加 0.3px²:给每个 splat 一个最小屏幕footprint(抗锯齿膨胀)
  float a = Sigma2[0][0] + 0.3, b = Sigma2[1][0], c = Sigma2[1][1] + 0.3;

  // conic = 2×2 协方差的逆,供片元算高斯衰减
  float det = a * c - b * b;
  if (det <= 0.) { gl_Position = vec4(0., 0., 2., 1.); return; }
  vConic = vec3(c, -b, a) / det;

  // 特征分解:λ = mid ± sqrt(((a-c)/2)² + b²),主轴 = 椭圆的长短轴方向
  float mid = 0.5 * (a + c);
  float rad = sqrt(max(0., mid * mid - det));
  float l1 = mid + rad, l2 = max(mid - rad, 1e-4);
  vec2 e1 = (abs(b) < 1e-9) ? ((a >= c) ? vec2(1., 0.) : vec2(0., 1.))
                            : normalize(vec2(b, l1 - a));
  vec2 e2 = vec2(e1.y, -e1.x);

  // quad 半径取 3σ(99.7% 能量),并给个上限防近距离爆屏
  vec2 radiusPx = min(3. * sqrt(vec2(l1, l2)), 512.);
  vec2 offsetPx = corner.x * e1 * radiusPx.x + corner.y * e2 * radiusPx.y;
  vPixPos = offsetPx;

  vec4 clip = uProj * viewC;
  clip.xy += offsetPx / uViewport * 2. * clip.w;   // 像素偏移换算回 NDC(乘 w 抵消透视除)
  gl_Position = clip;
  // 方向光:0.62 环境底 + 0.38 漫反射。训练资产的光照烤在球谐系数里,
  // 程序化资产用 N·L 近似出同一件事 —— "面朝光的地方亮"
  float lam = 0.62 + 0.38 * max(0., dot(iNormal, uLight));
  vColor = vec4(iColorA.rgb * lam, iColorA.a);
}`;

const FS = `#version 300 es
precision highp float;
in vec4 vColor;
in vec3 vConic;
in vec2 vPixPos;
in vec2 vCorner;
flat in float vQuad;
out vec4 frag;

void main() {
  // 高斯衰减:exp(-½ dᵀ Σ'⁻¹ d),d 为到椭圆中心的像素偏移
  float power = 0.5 * (vConic.x * vPixPos.x * vPixPos.x
                     + vConic.z * vPixPos.y * vPixPos.y)
                     + vConic.y * vPixPos.x * vPixPos.y;
  float alpha = vColor.a * exp(-power);

  // 教学开关:把撑起椭圆的包围 quad 描出来(配色学原站蓝图橙)
  if (vQuad > 0.5) {
    float edge = max(abs(vCorner.x), abs(vCorner.y));
    if (edge > 0.96) { frag = vec4(0.94, 0.55, 0.18, 0.85); return; }
  }
  if (alpha < 1. / 255.) discard;
  frag = vec4(vColor.rgb, alpha);
}`;

function compile(type, src) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
    throw new Error(gl.getShaderInfoLog(s));
  return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);

/* ---------- 3. 实例缓冲:17 float/实例,每帧按排序结果重写 ---------- */

const FLOATS = 17;  // center 3 + scale 3 + quat 4 + colorA 4 + normal 3
const src = new Float32Array(COUNT * FLOATS);      // 原始顺序
splats.forEach((s, i) => {
  src.set(s.center, i * FLOATS);
  src.set(s.scale, i * FLOATS + 3);
  src.set(s.quat, i * FLOATS + 6);
  src.set([...s.color, s.alpha], i * FLOATS + 10);
  src.set(s.normal, i * FLOATS + 14);
});
const sorted = new Float32Array(COUNT * FLOATS);   // 排序后写入这里再上传

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, sorted.byteLength, gl.DYNAMIC_DRAW);
const stride = FLOATS * 4;
[['iCenter', 3, 0], ['iScale', 3, 12], ['iQuat', 4, 24], ['iColorA', 4, 40], ['iNormal', 3, 56]]
  .forEach(([name, size, off]) => {
    const loc = gl.getAttribLocation(prog, name);
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, off);
    gl.vertexAttribDivisor(loc, 1);                // 每实例前进一步
  });

const depths = new Float32Array(COUNT);
const order = new Uint32Array(COUNT);

/* ---------- 4. 相机(轨道 + 惯性)与矩阵小工具 ---------- */

function perspective(fovY, aspect, near, far) {
  const f = 1 / Math.tan(fovY / 2), nf = 1 / (near - far);
  return new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0]);
}
function lookAt(eye, at, up) {
  const z = norm3(sub3(eye, at)), x = norm3(cross3(up, z)), y = cross3(z, x);
  return new Float32Array([
    x[0],y[0],z[0],0, x[1],y[1],z[1],0, x[2],y[2],z[2],0,
    -dot3(x,eye), -dot3(y,eye), -dot3(z,eye), 1]);
}
const sub3=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
const cross3=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const dot3=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
const norm3=a=>{const l=Math.hypot(...a);return[a[0]/l,a[1]/l,a[2]/l]};

const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let theta = 0.9, phi = 0.95;          // 方位角 / 仰角(初始接近原站 hero 的俯视)
let vTheta = 0, vPhi = 0;             // 惯性速度
let dragging = false, px = 0, py = 0;

canvas.addEventListener('pointerdown', e => {
  dragging = true; px = e.clientX; py = e.clientY;
  canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', e => {
  if (!dragging) return;
  vTheta = (e.clientX - px) * 0.005;
  vPhi = (e.clientY - py) * 0.004;
  theta -= vTheta; phi += vPhi;
  px = e.clientX; py = e.clientY;
});
canvas.addEventListener('pointerup', () => dragging = false);

/* ---------- 5. 主循环:排序 → 上传 → 实例化绘制 ---------- */

gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);   // 从远到近的经典 alpha 混合
gl.clearColor(0.247, 0.29, 0.196, 1);

const uView = gl.getUniformLocation(prog, 'uView');
const uProj = gl.getUniformLocation(prog, 'uProj');
const uViewport = gl.getUniformLocation(prog, 'uViewport');
const uFocal = gl.getUniformLocation(prog, 'uFocal');
// 方向光:左前上方的"棚灯",固定在世界空间(相机转、光不转)
{
  const L = [0.45, -0.3, 0.85], l = Math.hypot(...L);
  gl.uniform3f(gl.getUniformLocation(prog, 'uLight'), L[0] / l, L[1] / l, L[2] / l);
}
const uShowQuads = gl.getUniformLocation(prog, 'uShowQuads');
const quadsBox = document.getElementById('showQuads');

function frame() {
  const dpr = Math.min(devicePixelRatio || 1, 2);
  const w = canvas.clientWidth * dpr | 0, h = canvas.clientHeight * dpr | 0;
  if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
  gl.viewport(0, 0, w, h);

  // 惯性 + 缓慢自转(尊重 prefers-reduced-motion)
  if (!dragging) {
    theta -= vTheta; phi += vPhi;
    vTheta *= 0.94; vPhi *= 0.94;
    if (!reduceMotion) theta += 0.0018;
  }
  phi = Math.max(0.35, Math.min(1.25, phi));   // 仰角下限抬高:不让视线擦着垫子平面走

  const eye = [4.1 * Math.cos(phi) * Math.cos(theta),
               4.1 * Math.cos(phi) * Math.sin(theta),
               4.1 * Math.sin(phi)];
  const view = lookAt(eye, [0, 0, 0.05], [0, 0, 1]);
  const fovY = 38 * Math.PI / 180;
  const proj = perspective(fovY, w / h, 0.1, 100);
  const focalY = h / (2 * Math.tan(fovY / 2));

  // —— 排序:视空间 z 越小(越远)越先画。oryzo 把这一步丢给 wasm Worker,
  //    并用"排序索引纹理"间接寻址;这里 1400 个点直接每帧 argsort + 重排上传
  for (let i = 0; i < COUNT; i++) {
    const o = i * FLOATS;
    depths[i] = view[2] * src[o] + view[6] * src[o+1] + view[10] * src[o+2];
    order[i] = i;
  }
  order.sort((A, B) => depths[A] - depths[B]);       // 最远(z 最负)在前
  for (let k = 0; k < COUNT; k++)
    sorted.set(src.subarray(order[k] * FLOATS, (order[k] + 1) * FLOATS), k * FLOATS);
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, sorted);

  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.uniformMatrix4fv(uView, false, view);
  gl.uniformMatrix4fv(uProj, false, proj);
  gl.uniform2f(uViewport, w, h);
  gl.uniform2f(uFocal, focalY, focalY);              // 方像素:fx = fy
  gl.uniform1f(uShowQuads, quadsBox.checked ? 1 : 0);
  gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, COUNT);

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
worker-sort-demo.html(含内联 Worker)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Worker 异步深度排序 · 泼溅渲染的架构实验台</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; overflow: hidden; background: #2c3325; }
  canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
  canvas:active { cursor: grabbing; }
  .hud {
    position: fixed; left: 16px; top: 14px; color: #f2e8d5;
    font: 12px/1.7 "DM Mono", ui-monospace, monospace; letter-spacing: .06em;
    pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.4);
  }
  .hud b { font-size: 14px; letter-spacing: .12em; }
  .panel {
    position: fixed; right: 16px; top: 14px; color: #f2e8d5;
    font: 12px/2 "DM Mono", ui-monospace, monospace; letter-spacing: .04em;
    background: rgba(0,0,0,.25); padding: 10px 14px; border-radius: 10px; user-select: none;
  }
  .panel label { display: flex; align-items: center; gap: 7px; cursor: pointer; white-space: nowrap; }
  .panel input { accent-color: #f08c2e; }
  .panel input[type=range] { width: 120px; }
  .panel .sep { border-top: 1px solid #f2e8d544; margin: 6px 0; }
  .stats {
    position: fixed; left: 16px; bottom: 14px; color: #f2e8d5;
    font: 12px/1.8 "DM Mono", ui-monospace, monospace; pointer-events: none;
    text-shadow: 0 1px 2px rgba(0,0,0,.4);
  }
  .stats em { font-style: normal; color: #f08c2e; }
  .err { position: fixed; inset: 0; display: none; place-items: center; color: #f2e8d5; font: 14px system-ui; }
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div class="hud"><b>SORT LAB</b><br>三团半透明高斯球互相穿插 —— 排序错一帧就露馅</div>
<div class="panel">
  <label><input type="radio" name="mode" value="worker" checked> Worker 异步排(oryzo 的做法)</label>
  <label><input type="radio" name="mode" value="main"> 主线程每帧同步排</label>
  <label><input type="radio" name="mode" value="none"> 不排序</label>
  <div class="sep"></div>
  <label>模拟排序耗时 <input type="range" id="cost" min="0" max="48" value="0"> <span id="costV">0ms</span></label>
</div>
<div class="stats" id="stats"></div>
<div class="err" id="err">需要 WebGL2 支持。</div>

<script>
/* ============================================================================
 * Worker 异步深度排序 —— oryzo 泼溅渲染架构的实验台
 *
 * 半透明混合要求"从远到近"画,所以相机一动就得重排全部高斯。
 * 排序放哪儿是个架构决策,这个 demo 让三个选项当场对质:
 *
 *   ① Worker 异步(oryzo 的选择,原站是 Rust→wasm 的 SplatSorter):
 *      渲染永不等排序;代价是排序结果晚 1~N 帧到货,
 *      耗时拉高后能看到穿插处"游一下"才对(延迟帧数看左下角)。
 *   ② 主线程同步:顺序永远新鲜,但排序耗时直接吃掉帧预算 —— 拉高滑杆看 FPS 跳水。
 *   ③ 不排序:按生成顺序画,穿插处的前后关系随相机角度胡说八道。
 *
 *   消息协议按 oryzo 的形状复刻:INIT{positions} → READY;
 *   SORT{zRow, sortBuffer} → SORT_DONE{sortBuffer},索引缓冲 transferable
 *   往返 ping-pong,零克隆。位置数组只在 INIT 传一次。
 * ========================================================================== */

const canvas = document.getElementById('gl');
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false });
if (!gl) { document.getElementById('err').style.display = 'grid'; throw new Error('no webgl2'); }

/* ---------- 场景:三团互相穿插的半透明高斯球壳 ---------- */

const splats = [];
const rand = (a, b) => a + Math.random() * (b - a);
const gauss = () => (Math.random()+Math.random()+Math.random()+Math.random()-2)/2;

const BLOBS = [
  { c: [ 0.62,  0.0,  0.0], r: 0.62, color: [0.85, 0.55, 0.30] },  // 软木橙
  { c: [-0.34,  0.55, 0.0], r: 0.62, color: [0.48, 0.58, 0.32] },  // 橄榄绿
  { c: [-0.34, -0.55, 0.0], r: 0.62, color: [0.90, 0.85, 0.70] },  // 米白
];
for (const b of BLOBS) {
  for (let i = 0; i < 1100; i++) {
    // 球面均匀方向 × 半径 → 薄壳;壳与壳穿插的地方就是排序的照妖镜
    let d = [gauss(), gauss(), gauss()];
    const l = Math.hypot(...d) || 1; d = d.map(x => x / l);
    const rr = b.r * rand(0.96, 1.04);
    splats.push({
      center: [b.c[0]+d[0]*rr, b.c[1]+d[1]*rr, b.c[2]+d[2]*rr],
      scale: [rand(0.05, 0.075), rand(0.05, 0.075), 0.014],
      normal: d,
      color: b.color.map(x => x * rand(0.92, 1.08)),
      alpha: 0.55,
    });
  }
}
const COUNT = splats.length;

function quatFromNormal(n) {
  const d = n[2];
  if (d > 0.9999) return [0, 0, 0, 1];
  if (d < -0.9999) return [1, 0, 0, 0];
  const ax = [-n[1], n[0], 0];
  const l = Math.hypot(...ax), a = Math.acos(d), s = Math.sin(a/2)/l;
  return [ax[0]*s, ax[1]*s, ax[2]*s, Math.cos(a/2)];
}

/* ---------- 排序 Worker:内联 Blob,协议形状照抄 oryzo ---------- */

const workerSrc = `
let positions = null;
const busyWait = ms => { const t0 = performance.now(); while (performance.now() - t0 < ms); };
onmessage = e => {
  const { type, payload } = e.data;
  if (type === 'INIT') {
    positions = payload.positions;                 // 只传一次,常驻 Worker
    postMessage({ type: 'READY' });
  } else if (type === 'SORT') {
    const { zRow, cost, sortBuffer } = payload;
    busyWait(cost);                                // 模拟"排序很贵"(wasm 里是真算力)
    const n = positions.length / 3;
    const depths = new Float32Array(n);
    const idx = new Uint32Array(sortBuffer);
    for (let i = 0; i < n; i++) {
      depths[i] = zRow[0]*positions[3*i] + zRow[1]*positions[3*i+1] + zRow[2]*positions[3*i+2];
      idx[i] = i;
    }
    idx.sort((a, b) => depths[a] - depths[b]);
    postMessage({ type: 'SORT_DONE', sortBuffer }, [sortBuffer]);   // transferable 回传
  }
};`;
const worker = new Worker(URL.createObjectURL(new Blob([workerSrc], { type: 'text/javascript' })));

const positions = new Float32Array(COUNT * 3);
splats.forEach((s, i) => positions.set(s.center, i * 3));
let workerReady = false, sortInFlight = false, framesSinceRequest = 0, lagFrames = 0;
let spareBuffer = new ArrayBuffer(COUNT * 4);      // ping-pong 用的备用索引缓冲
worker.postMessage({ type: 'INIT', payload: { positions } });   // 结构化克隆一次,之后不再传
worker.onmessage = e => {
  if (e.data.type === 'READY') { workerReady = true; return; }
  if (e.data.type === 'SORT_DONE') {
    applyOrder(new Uint32Array(e.data.sortBuffer));
    spareBuffer = e.data.sortBuffer;               // 拿回所有权,下次继续用
    lagFrames = framesSinceRequest;                // 这批顺序"晚了几帧"
    sortInFlight = false;
  }
};

/* ---------- WebGL 管线(与本合集《高斯泼溅光栅化》同款,注释从简) ---------- */

const VS = `#version 300 es
precision highp float;
in vec3 iCenter; in vec3 iScale; in vec4 iQuat; in vec4 iColorA;
uniform mat4 uView, uProj; uniform vec2 uViewport, uFocal;
out vec4 vColor; out vec3 vConic; out vec2 vPixPos;
mat3 quatToMat3(vec4 q){float x=q.x,y=q.y,z=q.z,w=q.w;
  return mat3(1.-2.*(y*y+z*z),2.*(x*y+w*z),2.*(x*z-w*y),
              2.*(x*y-w*z),1.-2.*(x*x+z*z),2.*(y*z+w*x),
              2.*(x*z+w*y),2.*(y*z-w*x),1.-2.*(x*x+y*y));}
void main(){
  vec2 corner = vec2(float(gl_VertexID % 2), float(gl_VertexID / 2)) * 2. - 1.;
  vec4 viewC = uView * vec4(iCenter, 1.);
  if (viewC.z > -0.3) { gl_Position = vec4(0.,0.,2.,1.); return; }
  mat3 R = quatToMat3(iQuat);
  mat3 M = R * mat3(iScale.x,0.,0., 0.,iScale.y,0., 0.,0.,iScale.z);
  mat3 Sigma = M * transpose(M);
  float invZ = 1./viewC.z, invZ2 = invZ*invZ;
  mat3 J = mat3(uFocal.x*invZ,0.,0., 0.,uFocal.y*invZ,0.,
                -uFocal.x*viewC.x*invZ2, -uFocal.y*viewC.y*invZ2, 0.);
  mat3 T = J * mat3(uView);
  mat3 S2 = T * Sigma * transpose(T);
  float a = S2[0][0]+0.3, b = S2[1][0], c = S2[1][1]+0.3;
  float det = a*c - b*b;
  if (det <= 0.) { gl_Position = vec4(0.,0.,2.,1.); return; }
  vConic = vec3(c, -b, a) / det;
  float mid = 0.5*(a+c), rad = sqrt(max(0., mid*mid - det));
  float l1 = mid+rad, l2 = max(mid-rad, 1e-4);
  vec2 e1 = (abs(b)<1e-9) ? ((a>=c)?vec2(1.,0.):vec2(0.,1.)) : normalize(vec2(b, l1-a));
  vec2 e2 = vec2(e1.y, -e1.x);
  vec2 radiusPx = min(3.*sqrt(vec2(l1,l2)), 512.);
  vec2 offsetPx = corner.x*e1*radiusPx.x + corner.y*e2*radiusPx.y;
  vPixPos = offsetPx;
  vec4 clip = uProj * viewC;
  clip.xy += offsetPx / uViewport * 2. * clip.w;
  gl_Position = clip;
  vColor = iColorA;
}`;
const FS = `#version 300 es
precision highp float;
in vec4 vColor; in vec3 vConic; in vec2 vPixPos; out vec4 frag;
void main(){
  float power = 0.5*(vConic.x*vPixPos.x*vPixPos.x + vConic.z*vPixPos.y*vPixPos.y)
              + vConic.y*vPixPos.x*vPixPos.y;
  float alpha = vColor.a * exp(-power);
  if (alpha < 1./255.) discard;
  frag = vec4(vColor.rgb, alpha);
}`;

function compile(type, code) {
  const s = gl.createShader(type);
  gl.shaderSource(s, code); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);

const FLOATS = 14;
const src = new Float32Array(COUNT * FLOATS);
splats.forEach((s, i) => {
  src.set(s.center, i*FLOATS);
  src.set(s.scale, i*FLOATS+3);
  src.set(quatFromNormal(s.normal), i*FLOATS+6);
  src.set([...s.color, s.alpha], i*FLOATS+10);
});
const sorted = new Float32Array(COUNT * FLOATS);

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, sorted.byteLength, gl.DYNAMIC_DRAW);
[['iCenter',3,0],['iScale',3,12],['iQuat',4,24],['iColorA',4,40]].forEach(([n,sz,off]) => {
  const loc = gl.getAttribLocation(prog, n);
  gl.enableVertexAttribArray(loc);
  gl.vertexAttribPointer(loc, sz, gl.FLOAT, false, FLOATS*4, off);
  gl.vertexAttribDivisor(loc, 1);
});

function applyOrder(idx) {
  for (let k = 0; k < COUNT; k++)
    sorted.set(src.subarray(idx[k]*FLOATS, (idx[k]+1)*FLOATS), k*FLOATS);
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, sorted);
}
// 初始上传一份自然顺序("不排序"模式直接用它)
applyOrder(Uint32Array.from({ length: COUNT }, (_, i) => i));

/* ---------- 主线程同步排序(模式②用,busy-wait 同款注水) ---------- */

const depths = new Float32Array(COUNT), order = new Uint32Array(COUNT);
function sortOnMain(zRow, cost) {
  const t0 = performance.now();
  while (performance.now() - t0 < cost);
  for (let i = 0; i < COUNT; i++) {
    depths[i] = zRow[0]*positions[3*i] + zRow[1]*positions[3*i+1] + zRow[2]*positions[3*i+2];
    order[i] = i;
  }
  order.sort((a, b) => depths[a] - depths[b]);
  applyOrder(order);
  return performance.now() - t0;
}

/* ---------- 相机 / 循环 / 统计 ---------- */

function perspective(fovY, aspect, near, far) {
  const f = 1/Math.tan(fovY/2), nf = 1/(near-far);
  return new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0]);
}
const sub3=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
const cross3=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const dot3=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
const norm3=a=>{const l=Math.hypot(...a);return[a[0]/l,a[1]/l,a[2]/l]};
function lookAt(eye, at, up) {
  const z = norm3(sub3(eye,at)), x = norm3(cross3(up,z)), y = cross3(z,x);
  return new Float32Array([x[0],y[0],z[0],0, x[1],y[1],z[1],0, x[2],y[2],z[2],0,
    -dot3(x,eye),-dot3(y,eye),-dot3(z,eye),1]);
}

const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let theta = 0.3, phi = 0.5, vT = 0, vP = 0, dragging = false, px = 0, py = 0;
canvas.addEventListener('pointerdown', e => { dragging = true; px = e.clientX; py = e.clientY; canvas.setPointerCapture(e.pointerId); });
canvas.addEventListener('pointermove', e => {
  if (!dragging) return;
  vT = (e.clientX-px)*0.005; vP = (e.clientY-py)*0.004;
  theta -= vT; phi += vP; px = e.clientX; py = e.clientY;
});
canvas.addEventListener('pointerup', () => dragging = false);

gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0.173, 0.2, 0.145, 1);

const U = n => gl.getUniformLocation(prog, n);
const costEl = document.getElementById('cost'), costV = document.getElementById('costV');
const statsEl = document.getElementById('stats');
costEl.addEventListener('input', () => costV.textContent = costEl.value + 'ms');

let fps = 60, lastT = performance.now(), mainSortMs = 0;

function frame() {
  const now = performance.now();
  fps = fps * 0.92 + (1000 / Math.max(1, now - lastT)) * 0.08;   // EMA
  lastT = now;

  const dpr = Math.min(devicePixelRatio || 1, 2);
  const w = canvas.clientWidth*dpr|0, h = canvas.clientHeight*dpr|0;
  if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
  gl.viewport(0, 0, w, h);

  if (!dragging) {
    theta -= vT; phi += vP; vT *= 0.94; vP *= 0.94;
    if (!reduceMotion) theta += 0.006;             // 转快点,逼排序露马脚
  }
  phi = Math.max(-1.1, Math.min(1.1, phi));

  const eye = [3.4*Math.cos(phi)*Math.cos(theta), 3.4*Math.cos(phi)*Math.sin(theta), 3.4*Math.sin(phi)];
  const view = lookAt(eye, [0, 0, 0], [0, 0, 1]);
  const fovY = 40*Math.PI/180;
  const proj = perspective(fovY, w/h, 0.1, 100);
  const zRow = [view[2], view[6], view[10]];
  const cost = +costEl.value;
  const mode = document.querySelector('input[name=mode]:checked').value;

  if (mode === 'main') {
    mainSortMs = sortOnMain(zRow, cost);
  } else if (mode === 'worker' && workerReady && !sortInFlight) {
    // 一次只挂一单:新相机角度会在下一单里补上(oryzo 同策略)
    sortInFlight = true; framesSinceRequest = 0;
    worker.postMessage({ type: 'SORT', payload: { zRow, cost, sortBuffer: spareBuffer } }, [spareBuffer]);
  }
  if (sortInFlight) framesSinceRequest++;

  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.uniformMatrix4fv(U('uView'), false, view);
  gl.uniformMatrix4fv(U('uProj'), false, proj);
  gl.uniform2f(U('uViewport'), w, h);
  gl.uniform2f(U('uFocal'), h/(2*Math.tan(fovY/2)), h/(2*Math.tan(fovY/2)));
  gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, COUNT);

  statsEl.innerHTML = `FPS <em>${fps.toFixed(0)}</em> · ${COUNT.toLocaleString()} 高斯 · ` +
    (mode === 'worker' ? `排序延迟 <em>${lagFrames}</em> 帧(异步到货)`
     : mode === 'main' ? `主线程排序占用 <em>${mainSortMs.toFixed(1)}ms</em>/帧`
     : `未排序 —— 注意穿插处的前后关系错乱`);

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
mirror-reflection-demo.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>镜像泼溅假反射 · ORYZO table_reflection 手法教学复刻</title>
<style>
  /* 深色亚克力"产品摄影桌":反射不是算出来的,是叠出来的 */
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; overflow: hidden;
    background: radial-gradient(120% 90% at 50% 12%, #33363a 0%, #1d1f22 55%, #121315 100%); }
  canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
  canvas:active { cursor: grabbing; }
  .hud {
    position: fixed; left: 16px; top: 14px; color: #f2e8d5;
    font: 12px/1.7 "DM Mono", ui-monospace, monospace; letter-spacing: .06em;
    pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.5);
  }
  .hud b { font-size: 14px; letter-spacing: .12em; }
  .panel {
    position: fixed; right: 16px; top: 14px; color: #f2e8d5;
    font: 12px/2 "DM Mono", ui-monospace, monospace; letter-spacing: .05em;
    display: grid; gap: 2px; user-select: none;
  }
  .panel label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
  .panel input[type=range] { width: 110px; accent-color: #f08c2e; }
  .panel input[type=checkbox] { accent-color: #f08c2e; }
  .hint {
    position: fixed; left: 16px; bottom: 14px; color: #f2e8d5; opacity: .7;
    font: 11px/1.6 "DM Mono", ui-monospace, monospace; pointer-events: none;
  }
  .err { position: fixed; inset: 0; display: none; place-items: center; color: #f2e8d5; font: 14px system-ui; }
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div class="hud"><b>FAKE REFLECTION</b><br>正身画一遍 · 镜像再画一遍(调暗调透明)</div>
<div class="panel">
  <label>反射强度 <input type="range" id="refA" min="0" max="80" value="40"></label>
  <label><input type="checkbox" id="fade" checked> 离面越远越淡(demo 加料)</label>
</div>
<div class="hint">拖拽旋转 · oryzo 的桌面倒影 = 第二份镜像泼溅(table_reflection.sog)opacity 0.5 叠画,不是实时反射</div>
<div class="err" id="err">需要 WebGL2 支持。</div>

<script>
/* ============================================================================
 * 镜像泼溅假反射 —— oryzo.ai 桌面倒影手法的教学复刻
 *
 * 原站真相(来自线上 bundle):倒影是独立加载的第二份泼溅资产
 * table_reflection.sog(0.5MB),onLoad 后设 opacity=0.5 叠画在主场景之下。
 * 没有任何实时反射计算 —— 就是"把物体沿桌面翻个面、调暗调透明再画一遍",
 * 产品摄影里的黑亚克力反光,用 15 行渲染顺序换掉一整套 SSR。
 *
 * demo 做法:同一份程序化杯垫高斯,画两遍——
 *   pass A(先画):uZSign=-1 沿 z=0 镜像 + 全局透明度/亮度衰减 → 倒影
 *   pass B(后画):原样 → 正身
 * 相机限制在桌面以上,所以"倒影永远画在正身之前"就是正确的遮挡序。
 * 泼溅管线本身(协方差投影/conic/排序)与本合集《高斯泼溅光栅化》一文相同。
 * ========================================================================== */

const canvas = document.getElementById('gl');
// premultipliedAlpha:false —— 让画布直接以 straight alpha 叠到 CSS 的"桌面"渐变上
const gl = canvas.getContext('webgl2', { antialias: false, alpha: true, premultipliedAlpha: false });
if (!gl) { document.getElementById('err').style.display = 'grid'; throw new Error('no webgl2'); }

/* ---------- 场景:悬浮在桌面上方的软木杯垫(只有杯垫,桌面交给 CSS) ---------- */

const splats = [];
const rand = (a, b) => a + Math.random() * (b - a);
const Z0 = 0.34;                     // 杯垫悬浮高度:留出空隙让倒影读得出来

function quatFromNormal(n) {
  const d = n[2];
  if (d > 0.9999) return [0, 0, 0, 1];
  if (d < -0.9999) return [1, 0, 0, 0];
  const ax = [-n[1], n[0], 0];       // cross((0,0,1), n)
  const l = Math.hypot(...ax), a = Math.acos(d), s = Math.sin(a / 2) / l;
  return [ax[0] * s, ax[1] * s, ax[2] * s, Math.cos(a / 2)];
}
/* 数据造法同《高斯泼溅渲染系统》主 demo:连贯软木纹理 + 车削轮廓黄金角采样 + 法线。
 * 倒影里主要看到的是底面,所以底面用足密度。 */
function makeCorkSampler() {
  const S = 512, c = document.createElement('canvas');
  c.width = c.height = S;
  const g = c.getContext('2d');
  g.fillStyle = '#c99a5f'; g.fillRect(0, 0, S, S);
  const shades = ['#e0b67c', '#c78f54', '#b67e45', '#d8ab70', '#a8703a', '#d1a066', '#bd8a50', '#e6c08a'];
  for (let i = 0; i < 3200; i++) {
    g.fillStyle = shades[Math.random() * shades.length | 0];
    g.globalAlpha = rand(0.35, 0.85);
    g.beginPath();
    g.ellipse(Math.random() * S, Math.random() * S, rand(2, 8), rand(1.5, 5), Math.random() * Math.PI, 0, 7);
    g.fill();
  }
  g.globalAlpha = 0.06;
  for (let i = 0; i < 46; i++) {
    g.fillStyle = Math.random() < 0.5 ? '#8a5c2e' : '#eec48d';
    g.beginPath();
    g.ellipse(Math.random() * S, Math.random() * S, rand(40, 110), rand(30, 90), Math.random() * Math.PI, 0, 7);
    g.fill();
  }
  g.globalAlpha = 1;
  for (let i = 0; i < 320; i++) {
    g.fillStyle = '#4a2f18';
    g.globalAlpha = rand(0.5, 0.85);
    g.beginPath();
    g.ellipse(Math.random() * S, Math.random() * S, rand(0.8, 2.4), rand(0.8, 2), Math.random() * Math.PI, 0, 7);
    g.fill();
  }
  const d = g.getImageData(0, 0, S, S).data;
  return (x, y) => {
    const u = Math.min(S - 1, Math.max(0, (x * 0.48 + 0.5) * S | 0));
    const v = Math.min(S - 1, Math.max(0, (y * 0.48 + 0.5) * S | 0));
    const i = (v * S + u) * 4;
    return [d[i] / 255, d[i + 1] / 255, d[i + 2] / 255];
  };
}
const corkTex = makeCorkSampler();
const GA = Math.PI * (3 - Math.sqrt(5));

function revolve({ N, pos, nrm, ao, sMul = 1 }) {
  for (let k = 0; k < N; k++) {
    const t = (k + 0.5) / N, th = k * GA;
    const [r, z] = pos(t), n2 = nrm(t);
    const cx = r * Math.cos(th), cy = r * Math.sin(th);
    const n = [n2[0] * Math.cos(th), n2[0] * Math.sin(th), n2[1]];
    const col = corkTex(cx, cy), shade = ao(t) * rand(0.97, 1.03);
    const s = 0.0135 * sMul * rand(0.85, 1.15);
    splats.push({
      center: [cx, cy, Z0 + z],
      scale: [s, s, 0.005],
      quat: quatFromNormal(n),
      color: [col[0] * shade, col[1] * shade, col[2] * shade],
      alpha: 1,
      normal: n,
    });
  }
}
// 凹面 / 内坡 / 平沿 / 圆唇 / 外壁 / 底面
revolve({ N: 6000, pos: t => [0.74 * Math.sqrt(t), 0.10], nrm: () => [0, 1], ao: () => 0.88 });
revolve({ N: 1500, pos: t => [0.74 + t * 0.08, 0.10 + t * 0.055], nrm: () => [-0.566, 0.824], ao: t => 0.8 + t * 0.16 });
revolve({ N: 2300, pos: t => [Math.sqrt(0.82 ** 2 + t * (0.94 ** 2 - 0.82 ** 2)), 0.155], nrm: () => [0, 1], ao: () => 1.0 });
revolve({ N: 1700, pos: t => { const a = t * Math.PI / 2; return [0.94 + 0.05 * Math.sin(a), 0.105 + 0.05 * Math.cos(a)]; },
          nrm: t => { const a = t * Math.PI / 2; return [Math.sin(a), Math.cos(a)]; }, ao: t => 0.98 - t * 0.1 });
revolve({ N: 1900, pos: t => [0.99, 0.105 - t * 0.09], nrm: () => [1, 0], ao: t => 0.86 - t * 0.18 });
revolve({ N: 4600, pos: t => [0.97 * Math.sqrt(t), 0.008], nrm: () => [0, -1], ao: () => 0.72, sMul: 1.2 });
const COUNT = splats.length;

/* ---------- 着色器:与主文管线一致,多了 uZSign / uAlphaMul / uColorMul / uFadeK ---------- */

const VS = `#version 300 es
precision highp float;
in vec3 iCenter; in vec3 iScale; in vec4 iQuat; in vec4 iColorA;
in vec3 iNormal;           // 表面法线:方向光着色,镜像 pass 跟着翻面
uniform mat4 uView, uProj;
uniform vec2 uViewport, uFocal;
uniform float uZSign;      // 1 正身;-1 沿 z=0 镜像
uniform float uAlphaMul;   // 倒影全局透明度
uniform float uColorMul;   // 倒影调暗
uniform float uFadeK;      // 离面衰减系数(0 = 关)
uniform vec3 uLight;       // 世界空间方向光(已归一化)
out vec4 vColor; out vec3 vConic; out vec2 vPixPos;

mat3 quatToMat3(vec4 q){float x=q.x,y=q.y,z=q.z,w=q.w;
  return mat3(1.-2.*(y*y+z*z),2.*(x*y+w*z),2.*(x*z-w*y),
              2.*(x*y-w*z),1.-2.*(x*x+z*z),2.*(y*z+w*x),
              2.*(x*z+w*y),2.*(y*z-w*x),1.-2.*(x*x+y*y));}

void main(){
  vec2 corner = vec2(float(gl_VertexID % 2), float(gl_VertexID / 2)) * 2. - 1.;
  vec3 center = vec3(iCenter.xy, iCenter.z * uZSign);       // 镜像:z 取反
  vec4 viewC = uView * vec4(center, 1.);
  if (viewC.z > -0.3) { gl_Position = vec4(0.,0.,2.,1.); return; }   // 近平面剔除

  mat3 R = quatToMat3(iQuat);
  mat3 M = R * mat3(iScale.x,0.,0., 0.,iScale.y,0., 0.,0.,iScale.z);
  // 镜像下协方差要跟着变:Σ' = FΣF^T,F=diag(1,1,-1) ⇔ 把 M 的第三行取反
  if (uZSign < 0.) { M[0].z = -M[0].z; M[1].z = -M[1].z; M[2].z = -M[2].z; }
  mat3 Sigma = M * transpose(M);

  float invZ = 1. / viewC.z, invZ2 = invZ * invZ;
  mat3 J = mat3(uFocal.x*invZ,0.,0., 0.,uFocal.y*invZ,0.,
                -uFocal.x*viewC.x*invZ2, -uFocal.y*viewC.y*invZ2, 0.);
  mat3 T = J * mat3(uView);
  mat3 S2 = T * Sigma * transpose(T);
  float a = S2[0][0]+0.3, b = S2[1][0], c = S2[1][1]+0.3;
  float det = a*c - b*b;
  if (det <= 0.) { gl_Position = vec4(0.,0.,2.,1.); return; }
  vConic = vec3(c, -b, a) / det;
  float mid = 0.5*(a+c), rad = sqrt(max(0., mid*mid - det));
  float l1 = mid+rad, l2 = max(mid-rad, 1e-4);
  vec2 e1 = (abs(b)<1e-9) ? ((a>=c)?vec2(1.,0.):vec2(0.,1.)) : normalize(vec2(b, l1-a));
  vec2 e2 = vec2(e1.y, -e1.x);
  vec2 radiusPx = min(3.*sqrt(vec2(l1,l2)), 512.);
  vec2 offsetPx = corner.x*e1*radiusPx.x + corner.y*e2*radiusPx.y;
  vPixPos = offsetPx;
  vec4 clip = uProj * viewC;
  clip.xy += offsetPx / uViewport * 2. * clip.w;
  gl_Position = clip;

  // demo 加料:倒影随"离桌面深度"指数衰减(真反光桌也这样,因为反射路径变长)
  float fade = (uZSign < 0.) ? exp(-uFadeK * iCenter.z) : 1.;
  // 方向光:镜像 pass 里法线随 Σ' = FΣF^T 一起翻面,倒影的明暗才是"镜子里的"
  vec3 n = vec3(iNormal.xy, iNormal.z * uZSign);
  float lam = 0.62 + 0.38 * max(0., dot(n, uLight));
  vColor = vec4(iColorA.rgb * lam * uColorMul, iColorA.a * uAlphaMul * fade);
}`;

const FS = `#version 300 es
precision highp float;
in vec4 vColor; in vec3 vConic; in vec2 vPixPos;
out vec4 frag;
void main(){
  float power = 0.5*(vConic.x*vPixPos.x*vPixPos.x + vConic.z*vPixPos.y*vPixPos.y)
              + vConic.y*vPixPos.x*vPixPos.y;
  float alpha = vColor.a * exp(-power);
  if (alpha < 1./255.) discard;
  frag = vec4(vColor.rgb, alpha);
}`;

function compile(type, srcCode) {
  const s = gl.createShader(type);
  gl.shaderSource(s, srcCode); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
{ // 方向光:左前上方"棚灯",固定在世界空间
  const L = [0.45, -0.3, 0.85], l = Math.hypot(...L);
  gl.uniform3f(gl.getUniformLocation(prog, 'uLight'), L[0] / l, L[1] / l, L[2] / l);
}

/* ---------- 双实例缓冲:正身 / 镜像各自按视深排序 ---------- */

const FLOATS = 17;   // center 3 + scale 3 + quat 4 + colorA 4 + normal 3
const src = new Float32Array(COUNT * FLOATS);
splats.forEach((s, i) => {
  src.set(s.center, i * FLOATS);
  src.set(s.scale, i * FLOATS + 3);
  src.set(s.quat, i * FLOATS + 6);
  src.set([...s.color, s.alpha], i * FLOATS + 10);
  src.set(s.normal, i * FLOATS + 14);
});

function makeVAO() {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);
  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, COUNT * FLOATS * 4, gl.DYNAMIC_DRAW);
  [['iCenter',3,0],['iScale',3,12],['iQuat',4,24],['iColorA',4,40],['iNormal',3,56]].forEach(([n,sz,off]) => {
    const loc = gl.getAttribLocation(prog, n);
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, sz, gl.FLOAT, false, FLOATS*4, off);
    gl.vertexAttribDivisor(loc, 1);
  });
  return { vao, vbo, buf: new Float32Array(COUNT * FLOATS) };
}
const passMain = makeVAO();      // 正身
const passMirr = makeVAO();      // 镜像
const depths = new Float32Array(COUNT), order = new Uint32Array(COUNT);

function sortInto(pass, view, zSign) {
  for (let i = 0; i < COUNT; i++) {
    const o = i * FLOATS;
    depths[i] = view[2]*src[o] + view[6]*src[o+1] + view[10]*(src[o+2]*zSign);
    order[i] = i;
  }
  order.sort((A, B) => depths[A] - depths[B]);
  for (let k = 0; k < COUNT; k++)
    pass.buf.set(src.subarray(order[k]*FLOATS, (order[k]+1)*FLOATS), k*FLOATS);
  gl.bindBuffer(gl.ARRAY_BUFFER, pass.vbo);
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, pass.buf);
}

/* ---------- 相机与主循环 ---------- */

function perspective(fovY, aspect, near, far) {
  const f = 1/Math.tan(fovY/2), nf = 1/(near-far);
  return new Float32Array([f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,2*far*near*nf,0]);
}
const sub3=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
const cross3=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const dot3=(a,b)=>a[0]*b[0]+a[1]*b[1]+a[2]*b[2];
const norm3=a=>{const l=Math.hypot(...a);return[a[0]/l,a[1]/l,a[2]/l]};
function lookAt(eye, at, up) {
  const z = norm3(sub3(eye,at)), x = norm3(cross3(up,z)), y = cross3(z,x);
  return new Float32Array([x[0],y[0],z[0],0, x[1],y[1],z[1],0, x[2],y[2],z[2],0,
    -dot3(x,eye),-dot3(y,eye),-dot3(z,eye),1]);
}

const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let theta = 0.7, phi = 0.35, vT = 0, vP = 0, dragging = false, px = 0, py = 0;
canvas.addEventListener('pointerdown', e => { dragging = true; px = e.clientX; py = e.clientY; canvas.setPointerCapture(e.pointerId); });
canvas.addEventListener('pointermove', e => {
  if (!dragging) return;
  vT = (e.clientX-px)*0.005; vP = (e.clientY-py)*0.004;
  theta -= vT; phi += vP; px = e.clientX; py = e.clientY;
});
canvas.addEventListener('pointerup', () => dragging = false);

gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
// alpha 通道单独累积,保证与 CSS 背景的最终合成正确
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0, 0, 0, 0);

const U = n => gl.getUniformLocation(prog, n);
const refA = document.getElementById('refA'), fadeBox = document.getElementById('fade');

function frame() {
  const dpr = Math.min(devicePixelRatio || 1, 2);
  const w = canvas.clientWidth*dpr|0, h = canvas.clientHeight*dpr|0;
  if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
  gl.viewport(0, 0, w, h);

  if (!dragging) {
    theta -= vT; phi += vP; vT *= 0.94; vP *= 0.94;
    if (!reduceMotion) theta += 0.0016;
  }
  phi = Math.max(0.12, Math.min(1.1, phi));   // 相机永远在桌面之上 → 倒影先画即正确遮挡

  const eye = [3.6*Math.cos(phi)*Math.cos(theta), 3.6*Math.cos(phi)*Math.sin(theta), 3.6*Math.sin(phi)+Z0];
  const view = lookAt(eye, [0, 0, Z0*0.7], [0, 0, 1]);
  const fovY = 38*Math.PI/180;
  const proj = perspective(fovY, w/h, 0.1, 100);
  const focalY = h/(2*Math.tan(fovY/2));

  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.uniformMatrix4fv(U('uView'), false, view);
  gl.uniformMatrix4fv(U('uProj'), false, proj);
  gl.uniform2f(U('uViewport'), w, h);
  gl.uniform2f(U('uFocal'), focalY, focalY);

  // pass A:倒影(先画 = 永远垫底,oryzo 的 renderOrder 干的就是这件事)
  sortInto(passMirr, view, -1);
  gl.bindVertexArray(passMirr.vao);
  gl.uniform1f(U('uZSign'), -1);
  gl.uniform1f(U('uAlphaMul'), refA.value / 100);
  gl.uniform1f(U('uColorMul'), 0.62);
  gl.uniform1f(U('uFadeK'), fadeBox.checked ? 1.6 : 0);
  gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, COUNT);

  // pass B:正身
  sortInto(passMain, view, 1);
  gl.bindVertexArray(passMain.vao);
  gl.uniform1f(U('uZSign'), 1);
  gl.uniform1f(U('uAlphaMul'), 1);
  gl.uniform1f(U('uColorMul'), 1);
  gl.uniform1f(U('uFadeK'), 0);
  gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, COUNT);

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>