Skip to content

在offscreenCanvas中渲染

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

<script lang="ts" setup>
import { onMounted, onUnmounted, ref, nextTick } from 'vue'

const isRunning = ref(false)

const onRunning = async () => {
  const canvas: any = document.getElementById('offscreenCanvas')

  const wrapDomStyle = getComputedStyle(canvas)
  const width = parseInt(wrapDomStyle.width, 10)
  const height = parseInt(wrapDomStyle.height, 10)

  // 需要设置canvas的width和height
  canvas.width = width
  canvas.height = height


  const workerCode = `
    async function init(canvas) {
      // 拿到 GPU 适配器(物理显卡),再从适配器创建逻辑设备
      // 逻辑设备是后续所有 WebGPU 操作的入口
      const adapter = await navigator.gpu.requestAdapter();
      const device = await adapter.requestDevice();

      // 从 OffscreenCanvas 上拿 webgpu context,和 canvas 2d 拿法一样
      const context = canvas.getContext('webgpu');

      // 获取当前显示器/浏览器偏好的纹理格式,通常是 bgra8unorm 或 rgba8unorm
      // 写死格式有时候会出问题,所以这里让浏览器自己决定
      const format = navigator.gpu.getPreferredCanvasFormat();

      // 把 context 和 device 绑定,告诉它用什么格式渲染
      // alphaMode: 'premultiplied' — 颜色存储时已预先乘以 alpha:
      //   普通(未预乘): rgba(255,0,0,0.5) 直接存,混合时计算 前景×alpha + 背景×(1-alpha)
      //   预乘:           rgba(255,0,0,0.5) 存为 rgba(127,0,0,0.5),混合时 前景 + 背景×(1-alpha)
      //   好处:半透明边缘不会出现黑边/白边,纹理插值更准确,GPU 内部也用此格式,推荐默认值
      context.configure({ device, format, alphaMode: 'premultiplied' });

      // 用三角形扇的方式拼出圆形,segments 越大越圆,128 基本肉眼看不出棱角
      const segments = 128;
      const vertices = [];
      for (let i = 0; i < segments; i++) {
        // 当前三角形两条边对应的角度
        const a0 = (i / segments) * Math.PI * 2;
        const a1 = ((i + 1) / segments) * Math.PI * 2;
        // 每个三角形:圆心 + 圆上相邻两点,半径 0.7(NDC 坐标,范围 -1~1)
        vertices.push(0, 0);
        vertices.push(Math.cos(a0) * 0.7, Math.sin(a0) * 0.7);
        vertices.push(Math.cos(a1) * 0.7, Math.sin(a1) * 0.7);
      }

      // 把顶点数据上传到 GPU 显存
      // Float32Array 每个元素 4 字节,所以 size = length * 4
      // COPY_DST 是因为要用 writeBuffer 写入数据
      const vertexBuffer = device.createBuffer({
        size: vertices.length * 4,
        usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
      });
      device.queue.writeBuffer(vertexBuffer, 0, new Float32Array(vertices));

      // uniform 只存 canvas 宽高两个 float,所以 size = 8 字节(2 * 4)
      // fragment shader 里要用宽高来做坐标归一化,所以得传进去
      const uniformBuffer = device.createBuffer({
        size: 8,
        usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
      });
      device.queue.writeBuffer(uniformBuffer, 0, new Float32Array([canvas.width, canvas.height]));

      const shader = device.createShaderModule({ code: \`
        // uniform 结构体,存分辨率,对应 JS 那边传进来的宽高
        struct Uniforms {
          resolution: vec2f,
        }
        // group(0) binding(0) 要和 bindGroupLayout 里的声明对得上
        @group(0) @binding(0) var<uniform> uniforms: Uniforms;

        // 顶点着色器,直接把 NDC 坐标透传,不做任何变换
        @vertex
        fn vs(@location(0) pos: vec2f) -> @builtin(position) vec4f {
          return vec4f(pos, 0.0, 1.0);
        }

        @fragment
        fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
          // fragCoord.xy 是像素坐标(左上角是 0,0),先除以分辨率归一化到 0~1,再 *2-1 映射到 -1~1
          var uv = (fragCoord.xy / uniforms.resolution) * 2.0 - 1.0;
          // canvas 不是正方形时,x 方向会被拉伸,乘以宽高比把它掰回来
          uv.x *= uniforms.resolution.x / uniforms.resolution.y;
          // 到圆心的距离,用来判断当前像素在圆的哪个位置
          let dist = length(uv);
          // smoothstep 做边缘抗锯齿,在 0.68~0.70 这一小段距离内从 1 渐变到 0
          let alpha = 1.0 - smoothstep(0.68, 0.70, dist);
          // 从蓝色渐变到紫色,dist 越大越偏紫
          let color = mix(
            vec3f(0.2, 0.5, 1.0),
            vec3f(0.8, 0.3, 1.0),
            dist / 0.70
          );
          // 预乘 alpha 输出,配合 configure 里的 premultiplied 和 blend 设置
          return vec4f(color * alpha, alpha);
        }
      \` });

      // bindGroupLayout 描述 shader 里 uniform 的结构
      // 这里只有一个 uniform buffer,绑在 binding=0,只有 fragment 阶段用得到
      const bindGroupLayout = device.createBindGroupLayout({
        entries: [{
          binding: 0,
          visibility: GPUShaderStage.FRAGMENT,
          buffer: { type: 'uniform' },
        }],
      });

      // bindGroup 是真正把 uniformBuffer 和 binding=0 关联起来的地方
      // layout 和 entries 的 binding 必须对应
      const bindGroup = device.createBindGroup({
        layout: bindGroupLayout,
        entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
      });

      const pipeline = device.createRenderPipeline({
        // 用自定义的 layout 替代 'auto',因为我们手动创建了 bindGroupLayout
        layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
        vertex: {
          module: shader,
          entryPoint: 'vs',
          buffers: [{
            arrayStride: 8,                  // 每个顶点 2 个 float,8 字节
            attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }],
          }],
        },
        fragment: {
          module: shader,
          entryPoint: 'fs',
          targets: [{
            format,
            // 标准 alpha 混合,让圆形边缘能和背景正确叠加
            blend: {
              color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
              alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
            },
          }],
        },
        primitive: { topology: 'triangle-list' },
      });

      function frame() {
        const encoder = device.createCommandEncoder(); // 每帧必须新建:encoder 是一次性对象,finish() 后即失效
        const pass = encoder.beginRenderPass({
          colorAttachments: [{
            view: context.getCurrentTexture().createView(),
            clearValue: { r: 0.1, g: 0.1, b: 0.18, a: 1 },  // 深蓝色背景
            loadOp: 'clear',    // 每帧开始先清屏
            storeOp: 'store',   // 渲染结果写回纹理
          }],
        });
        pass.setPipeline(pipeline);
        pass.setBindGroup(0, bindGroup);       // 绑定 uniform(分辨率)
        pass.setVertexBuffer(0, vertexBuffer); // 绑定顶点数据
        pass.draw(segments * 3);               // 128 个三角形,每个 3 个顶点
        pass.end();
        device.queue.submit([encoder.finish()]);
        requestAnimationFrame(frame);
      }
      frame();
    }

    // Worker 收到主线程传来的 OffscreenCanvas 后启动渲染
    self.onmessage = ({ data }) => init(data.canvas);
  `

  const blob = new Blob([workerCode], { type: 'application/javascript' })
  const worker = new Worker(URL.createObjectURL(blob))

  const offscreen = canvas.transferControlToOffscreen()
  worker.postMessage({ canvas: offscreen }, [offscreen])
}

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

const destroy = () => {
}

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

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

SSGI(屏幕空间全局光照)

展开SSGI(屏幕空间全局光照)说明
点击运行
<template>
  <div>
    <div @click="isShow = !isShow" class="pointer">{{isShow ? '隐藏' : '展开'}}SSGI(屏幕空间全局光照)说明</div>
    <div v-if="isShow">
      <p>
        <strong>SSGI(Screen Space Global Illumination,屏幕空间全局光照)</strong>
        是一种实时间接光照技术。传统直接光照只计算光源→物体表面→眼睛这一条路径,而全局光照还要模拟光线在物体间多次弹射所产生的<em>间接光</em>——例如红色球体将红色"渗染"到旁边白色球体上的"颜色溢出(Color Bleeding)"效果。
      </p>
      <p>
        SSGI 的核心思想:<strong>不在三维世界空间里追踪光线,而是在已渲染完成的二维屏幕缓冲区中采样</strong>。由于只操作屏幕像素,速度远快于路径追踪,但代价是屏幕外的物体无法贡献间接光(屏幕空间的固有局限)。
      </p>
      <h4>SSGI 完整渲染步骤</h4>
      <ol>
        <li>
          <strong>Step 1 — G-Buffer Pass(几何缓冲 Pass)</strong><br>
          将场景渲染到多张离屏纹理中,分别存储:
          <span style="display: inline-block;color: #00d4ff;background: rgba(0, 212, 255, 0.15);border-radius: 4px;margin-right: 4px;padding: 0 5px;">世界坐标</span>
          <span style="display: inline-block;color: #00d4ff;background: rgba(0, 212, 255, 0.15);border-radius: 4px;margin-right: 4px;padding: 0 5px;">法线</span>
          <span style="display: inline-block;color: #00d4ff;background: rgba(0, 212, 255, 0.15);border-radius: 4px;margin-right: 4px;padding: 0 5px;">Albedo 颜色</span>
          <span style="display: inline-block;color: #00d4ff;background: rgba(0, 212, 255, 0.15);border-radius: 4px;margin-right: 4px;padding: 0 5px;">深度</span>。
          这些数据是后续 SSGI 采样的"数据库"。
        </li>
        <li>
          <strong>Step 2 — SSGI 间接光采样 Pass</strong><br>
          对屏幕上的每个像素:读取其法线,在法线半球方向上生成若干采样方向(余弦加权,靠近法线方向概率更高);<span style="color: orange;">沿每个方向在深度缓冲中步进(Ray March)【这个是通过depth模拟3d中的光线步进】</span>,找到命中的屏幕像素,将其颜色乘以余弦权重后累加为间接光贡献。采样数越多,结果越平滑,但代价越高。
        </li>
        <li>
          <strong>Step 3 — 去噪 / 双边模糊 Pass</strong><br>
          少量采样(如 8 次)必然产生噪点。双边模糊(Bilateral Blur)在空间上平滑噪点,同时通过边缘感知权重(比较相邻像素颜色差异)避免模糊跨越物体边界,防止光照"渗透"到不该有的区域。
        </li>
        <li>
          <strong>Step 4 — 合成 Pass(Composite)</strong><br>
          将直接光照(漫反射 + 高光)与间接光照(SSGI GI 结果)叠加。可选加入环境光遮蔽(AO)进一步增强接触阴影的真实感。
        </li>
        <li>
          <strong>Step 5 — Tone Mapping + Gamma 校正</strong><br>
          HDR 渲染(如 rgba16float)的像素值可能超过 1.0。Reinhard色调映射将 HDR 值平滑压缩到 [0, 1];再做 Gamma 校正(^ 1/2.2)将线性颜色空间转为显示器所需的 sRGB 空间。
        </li>
      </ol>
      <p>
        本案例未构建完整 G-Buffer,SSGI 采样步骤替换为基于世界坐标的数学近似色(fakeColor),用于演示余弦加权半球采样与颜色溢出的视觉效果。后处理 Pass 使用双边模糊对 GI 结果进行软化。
      </p>
    </div>
    <div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
    <canvas v-if="isRunning" id="ssgi" class="stage"></canvas>
  </div>
</template>

<script lang="ts" setup>
// 行主序 vs 列主序
// 对于一个 4×4 矩阵,逻辑上长这样:

// [m00  m01  m02  m03]   // 第0行
// [m10  m11  m12  m13]   // 第1行
// [m20  m21  m22  m23]   // 第2行
// [m30  m31  m32  m33]   // 第3行
// 行主序(C/JavaScript 直觉) — 按行依次存入数组:

// [m00, m01, m02, m03,  m10, m11, ...]
//  [0]  [1]  [2]  [3]   [4]  [5]
// element(row, col) = array[row*4 + col]

// 列主序(OpenGL/WebGPU/GLSL 惯例) — 按列依次存入数组:

// [m00, m10, m20, m30,  m01, m11, ...]
//  [0]  [1]  [2]  [3]   [4]  [5]
// element(row, col) = array[col*4 + row]

// ————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————

// 代码中的体现

// 写视图矩阵时:
// view[0] = xNorm[0]   // col=0, row=0
// view[1] = yAxis[0]   // col=0, row=1  ← 同一列的第2个元素,索引+1
// view[2] = zAxis[0]   // col=0, row=2
// view[4] = xNorm[1]   // col=1, row=0  ← 下一列从索引4开始
// 如果用行主序思维,view[1] 应该是第0行第1列(即 xNorm[1]),但这里存的是 yAxis[0](第1行第0列),正是列主序的特征——先填满一列,再填下一列。

// 矩阵乘法公式的推导

// ————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————

// 【proj × view(列主序矩阵乘法)】注释说明了为什么乘法用 proj[k*4+row] * view[col*4+k]:

// 列主序:element(row, col) = array[col*4 + row]

// C[col][row] = Σ A[k][row] * B[col][k]
//             = Σ proj[k*4 + row] * view[col*4 + k]
// 这和数学上的矩阵乘法完全一致,只是下标和数组索引之间做了列主序的转换。

// ————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————

// 为什么 GPU 用列主序? GLSL/WGSL shader 中 mat4 * vec4 按数学惯例(列向量右乘),列主序存储使 GPU 取一列数据时内存连续,访问效率更高。

// ————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————

import { onMounted, onUnmounted, ref, nextTick } from 'vue'

const isRunning = ref(false)
const isShow = ref(false)
const requestID = ref<any>()

let canvas: any
let device: any, context: any
let autoRotate = true
let isDragging = false
let lastX = 0, lastY = 0
let scenePipeline: any, postPipeline: any
let depthTexture: any, sceneTexture: any
let spheres: any[] = []
// camera:球面坐标控制摄像机位置,rotY 水平旋转,rotX 俯仰角
// 用球面坐标比直接存 xyz 更方便实现鼠标拖拽绕目标旋转
let camera = { x: 0, y: 3, z: 8, rotX: 0.3, rotY: 0 }
let time = 0
let postBindGroup: any // 后处理 pass 的纹理绑定,resize 时需重建


// ─────────────────────────────────────────────────────────────────────────
// 顶点着色器(sceneVS)
// 职责:把每个顶点从"模型空间"变换到"裁剪空间",并把世界坐标、法线、颜色作为 varying 传给片元着色器,供光照计算使用
// ─────────────────────────────────────────────────────────────────────────
const sceneVS = `
  // 为什么定义 Uniforms struct:WGSL 要求 uniform buffer 有明确的内存布局声明
  // struct 的字段顺序和类型必须与 JS 侧 Float32Array 写入的内容一一对应
  struct Uniforms {
    viewProj: mat4x4f,      // 投影矩阵 × 视图矩阵,把世界坐标→裁剪坐标
    model: mat4x4f,         // 模型矩阵,把局部坐标→世界坐标(含位移动画)
    normalMatrix: mat4x4f,  // 法线矩阵(model 的逆转置),保证法线方向正确
    color: vec3f,           // 球体固有色,由 JS 每球单独传入
    lightPos: vec3f,        // 光源世界坐标(动态旋转)
    cameraPos: vec3f,       // 摄像机世界坐标,用于计算高光视线方向
    time: f32,              // 当前时间,用于 GI 采样方向的时序变化
  }

  // @group(0) @binding(0):每个球体拥有独立的 bind group
  // 每次 drawIndexed 前都会切换到本球的 bind group,从而读取到正确的 model/color 数据
  @binding(0) @group(0) var<uniform> uniforms: Uniforms;

  struct VertexInput {
    // @location(0/1) 与 pipeline 中 buffers[].attributes[].shaderLocation 对应。
    // arrayStride=24:每顶点 6 个 float(位置3 + 法线3)× 4 字节 = 24 字节。
    @location(0) position: vec3f,  // offset=0,前 12 字节
    @location(1) normal: vec3f,    // offset=12,后 12 字节
  }
  
  struct VertexOutput {
    // @builtin(position):GPU 光栅化器必须的裁剪坐标输出,决定像素覆盖
    @builtin(position) position: vec4f,
    @location(0) worldPos: vec3f,  // 世界坐标,片元着色器用于计算光照距离
    // 世界法线,用于 NdotL / NdotH 计算【渲染中的两个点积
    // NdotL = dot(N, L) — 法线与光照方向的夹角余弦
    // NdotH = dot(N, H) — 法线与半角向量(Halfway Vector,即 L<L (Light Direction) — 从着色点指向光源的方向> 和 V<V (View Direction) — 从着色点指向摄像机的方向> 的中间方向)的夹角余弦】
    @location(1) normal: vec3f,    
    @location(2) color: vec3f,     // 直接透传颜色,避免片元着色器再访问 uniform
  }

  @vertex
  fn main(input: VertexInput) -> VertexOutput {
    var output: VertexOutput;

    // 先把顶点变换到世界空间:w=1.0 表示"点"(非方向),使平移生效
    // 世界坐标需要单独保留,因为后续片元着色器要用它计算光照方向
    let worldPos = (uniforms.model * vec4f(input.position, 1.0)).xyz;

    // 再把世界坐标变换到裁剪空间,供光栅化器使用
    output.position = uniforms.viewProj * vec4f(worldPos, 1.0);
    output.worldPos = worldPos;

    // 法线用 normalMatrix 变换:w=0.0 表示"方向",平移对方向无效
    // 如果 model 有非均匀缩放,必须用逆转置矩阵才能保持法线垂直于表面
    output.normal = normalize((uniforms.normalMatrix * vec4f(input.normal, 0.0)).xyz);
    
    output.color = uniforms.color;

    return output;
  }
`

// ─────────────────────────────────────────────────────────────────────────
// 片元着色器(sceneFS)
// 职责:对每个像素执行 PBR 风格直接光照 + 模拟 SSGI 间接光照,
//       输出 HDR 颜色(rgba16float),由后处理 Pass 再做 tone mapping。
// ─────────────────────────────────────────────────────────────────────────
const sceneFS = `
  struct Uniforms {
    viewProj: mat4x4f,
    model: mat4x4f,
    normalMatrix: mat4x4f,
    color: vec3f,
    lightPos: vec3f,
    cameraPos: vec3f,
    time: f32,
  }

  @binding(0) @group(0) var<uniform> uniforms: Uniforms;

  // 为什么用余弦加权半球采样(Cosine-Weighted Hemisphere Sampling)?
  // Lambert 漫反射的 BRDF 本身带有 cosθ 因子。余弦加权使采样密度正比于 cosθ
  // 从而把更多采样集中在对漫反射贡献最大的方向(法线附近),减少方差/噪点
  fn cosineSampleHemisphere(normal: vec3f, seed: vec2f) -> vec3f {
    // Malley 方法:先在单位圆盘上均匀采样,再投影到半球
    // sqrt(seed.x):均匀分布映射到圆盘需要对 r 取 sqrt
    // 否则面积元 dA=r·dr·dθ 会导致圆盘中心过密
    let r = sqrt(seed.x);
    let theta = 2.0 * 3.14159265359 * seed.y;
    let x = r * cos(theta);
    let y = r * sin(theta);
    // 半球上的 z 分量(高度)由圆盘半径决定:z = sqrt(1 - r²)
    let z = sqrt(1.0 - r * r);

    // 将切线空间的采样方向转换到世界空间,需要构建以法线为 z 轴的正交基
    // 为什么用 select?
    // 若法线接近 (0,0,1),cross(up=(0,0,1), normal) ≈ 0
    // 会产生退化切线。此时改用 (1,0,0) 作为参考方向,保证正交基有效
    let up = select(vec3f(1.0, 0.0, 0.0), vec3f(0.0, 0.0, 1.0), abs(normal.z) < 0.999);
    let tangent = normalize(cross(up, normal));
    let bitangent = cross(normal, tangent); // 双切线

    // 用正交基把切线空间方向转到世界空间
    return normalize(tangent * x + bitangent * y + normal * z);
  }

  // 为什么用 sin-based hash 而非纹理查找?
  // GPU 着色器中没有全局随机数,sin 配合大常数能产生伪随机值
  // 不需要额外绑定噪声纹理,代码自包含,适合 demo 场景
  fn hash2(p: vec2f) -> vec2f {
    let n = sin(dot(p, vec2f(127.1, 311.7)));
    // 大乘数(43758、78558)使 sin 值扩散到足够大的范围后取小数,
    // 保证两个分量之间相关性极低,近似独立的均匀分布 [0,1)。
    return fract(vec2f(n) * vec2f(43758.5453, 78558.5453));
  }

  @fragment
  fn main(
    @location(0) worldPos: vec3f,
    @location(1) normal: vec3f,
    @location(2) color: vec3f,
  ) -> @location(0) vec4f {
    let N = normalize(normal);  // 重新 normalize,插值后可能不是单位向量
    let V = normalize(uniforms.cameraPos - worldPos);   // 视线方向
    let L = normalize(uniforms.lightPos - worldPos);    // 光源方向

    // ── 直接光照:漫反射(Lambertian) ──
    // max(..., 0):点积可为负(光源在背面),物理上负值无意义故截断
    // 0.6 是漫反射强度系数,防止整体过亮
    let NdotL = max(dot(N, L), 0.0);
    let diffuse = color * NdotL * 0.6;

    // ── 直接光照:高光(Blinn-Phong) ──
    // 为什么用半程向量 H 而不用 reflect()?
    // Blinn-Phong 用 dot(N,H) 近似镜面反射,计算量更小
    // 且在掠射角时比 Phong 更物理合理。指数 32 控制高光锐度
    let H = normalize(L + V);
    let spec = pow(max(dot(N, H), 0.0), 32.0);
    let specular = vec3f(spec * 0.3);

    // ── SSGI:模拟间接光照 ──
    // 对法线半球方向进行 8 次余弦加权采样,累积间接光贡献
    // 真实 SSGI 应在深度缓冲中步进(Ray March)寻找遮挡点
    // 本 demo 用 worldPos 的数学函数近似颜色,演示采样框架
    var gi = vec3f(0.0);
    let sampleCount = 8.0;  // 8 次:实时性与质量的折中

    for(var i = 0.0; i < sampleCount; i += 1.0) {
      let fi = i + 1.0;

      // 为什么 seed 含 uniforms.time?
      // 每帧旋转采样方向, 配合时序累积(TAA)可获得比单帧更多的有效样本
      let seed = hash2(vec2f(fi * 12.34, uniforms.time * 0.1));
      let sampleDir = cosineSampleHemisphere(N, seed);

      // fakeColor:用世界坐标的 sin/cos 近似"该方向可能看到的颜色"
      // 真实实现应是沿 sampleDir 在屏幕深度缓冲中步进后读取的像素颜色
      let fakeColor = vec3f(
        0.5 + 0.5 * sin(worldPos.x * 2.0 + sampleDir.x * 3.0),
        0.5 + 0.5 * cos(worldPos.z * 2.0 + sampleDir.y * 3.0),
        0.5 + 0.5 * sin(worldPos.y * 2.0 + sampleDir.z * 3.0)
      );

      // 颜色溢出(Color Bleeding):用 mix 让间接光颜色向本球颜色偏移,
      // 模拟物体自身颜色对反射光的"染色"效应(0.3 = 30% 近似环境色)。
      let bleed = mix(color, fakeColor, 0.3);

      // 余弦权重:与法线越对齐的方向贡献越大(Lambert 物理规律)。
      let weight = max(dot(N, sampleDir), 0.0);
      gi += bleed * weight;
    }

    // 除以采样数得到均值;0.5 系数控制 GI 整体强度,防止过曝。
    gi /= sampleCount;
    gi *= 0.5;

    // ── 简化 AO(Ambient Occlusion)──
    // 用法线 y 分量粗略模拟:朝上的面(N.y=1)受天光多,朝下(N.y=-1)受光少。
    // 结果范围 [0, 1],乘以直接光照,使朝下的面整体暗一些。
    let ao = 0.5 + 0.5 * N.y;

    // 合并:直接光 × AO + 间接光(GI 不受 AO 调制,因为它自身已含方向权重)
    var finalColor = (diffuse + specular) * ao + gi;

    // 微弱的世界坐标扰动,给球面增加细微颜色变化,防止过于均匀。
    let colorBleed = sin(worldPos.x * 3.0) * cos(worldPos.z * 3.0) * 0.05 + 0.95;
    finalColor *= colorBleed;

    // ── Reinhard Tone Mapping ──
    // 为什么需要:渲染目标是 rgba16float(HDR),值可超过 1.0。
    // Reinhard:mapped = x / (x + 1),将 [0, ∞) 平滑映射到 [0, 1),无截断。
    let mapped = finalColor / (finalColor + vec3f(1.0));

    // ── Gamma 校正 ──
    // 显示器在 sRGB 空间工作(约 gamma=2.2)。线性渲染值需做 ^ (1/2.2)
    // 转换,否则暗部看起来太暗、亮部看起来太平。
    let gamma = pow(mapped, vec3f(1.0 / 2.2));

    return vec4f(gamma, 1.0);
  }
`

// ─────────────────────────────────────────────────────────────────────────
// 后处理顶点着色器(postVS)
// 职责:生成覆盖整个屏幕的全屏四边形(2 个三角形),不需要任何顶点缓冲。
// ─────────────────────────────────────────────────────────────────────────
const postVS = `
  struct VertexOutput {
    @builtin(position) position: vec4f,
    @location(0) uv: vec2f,
  }

  @vertex
  fn main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
    // 为什么硬编码 6 个顶点而不上传 vertex buffer?
    // 后处理只需要一个全屏四边形,用 vertex_index 直接索引数组
    // 比创建 vertex buffer + bind 更简洁,是 WebGPU 后处理的惯用模式。
    var pos = array<vec2f, 6>(
      vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
      vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0)
    );

    // 为什么 UV 的 y 是反的(底部=1.0,顶部=0.0)?
    // NDC y=+1 在屏幕顶部,但纹理 UV y=0 在顶部、y=1 在底部。
    // 翻转 y 使纹理坐标与屏幕方向一致,否则图像会上下颠倒。
    var uvs = array<vec2f, 6>(
      vec2f(0.0, 1.0), vec2f(1.0, 1.0), vec2f(0.0, 0.0),
      vec2f(0.0, 0.0), vec2f(1.0, 1.0), vec2f(1.0, 0.0)
    );

    var output: VertexOutput;
    output.position = vec4f(pos[vertexIndex], 0.0, 1.0);
    output.uv = uvs[vertexIndex];
    return output;
  }
`

// ─────────────────────────────────────────────────────────────────────────
// 后处理片元着色器(postFS)
// 职责:对 SSGI 渲染结果做双边模糊(软化噪点)+ 晕影(Vignette),
//       输出到 canvas 最终显示缓冲。
// ─────────────────────────────────────────────────────────────────────────
const postFS = `
  @group(0) @binding(0) var sceneTexture: texture_2d<f32>;
  @group(0) @binding(1) var textureSampler: sampler;

  @fragment
  fn main(@location(0) uv: vec2f) -> @location(0) vec4f {
    // textureDimensions 返回纹理像素尺寸,用于把偏移像素数转成 UV 偏移量。
    let texSize = vec2f(textureDimensions(sceneTexture));

    var color = vec3f(0.0);
    var weightSum = 0.0;

    // radius=4.0:每步偏移 4 像素,5×5 核心覆盖约 ±8 像素范围,
    // 足以平滑 8 次 SSGI 采样的低频噪点而不严重损失清晰度。
    let radius = 4.0;
    let centerColor = textureSample(sceneTexture, textureSampler, uv).rgb;

    for(var x = -2.0; x <= 2.0; x += 1.0) {
      for(var y = -2.0; y <= 2.0; y += 1.0) {
        let offset = vec2f(x, y) * radius / texSize;
        let sampleUV = uv + offset;
        let sampleColor = textureSample(sceneTexture, textureSampler, sampleUV).rgb;

        // ── 双边模糊权重(Bilateral) ──
        // 为什么不用普通高斯模糊?普通模糊会跨越物体边界,
        // 导致背景颜色渗入前景(光晕/鬼影)。双边模糊加入颜色相似度权重:
        // 与中心像素颜色越接近,权重越大;差异越大,权重接近 0,
        // 从而自动保留边缘。
        let colorDiff = length(sampleColor - centerColor);
        // exp(-colorDiff * 2.0):颜色相似度权重(值域 (0,1])
        // 1/(1 + dist):空间距离权重,近处贡献更多
        let weight = exp(-colorDiff * 2.0) / (1.0 + length(vec2f(x, y)));

        color += sampleColor * weight;
        weightSum += weight;
      }
    }

    // 归一化加权和
    color /= weightSum;

    // 为什么 7:3 混合而不全用模糊结果?
    // 纯模糊会损失高频细节(高光边缘、颜色边界)。
    // 保留 70% 原始锐利像素,只混入 30% 柔化结果,
    // 在减少 GI 噪点的同时保持画面细节感。
    let finalCol = centerColor * 0.7 + color * 0.3;

    // ── Vignette(晕影) ──
    // 为什么加晕影?暗化屏幕四角,引导视线聚焦中央球体,
    // 是常见的电影感后处理。uv-0.5 将坐标原点移到屏幕中心,
    // length 计算到中心的距离,乘以 0.4 控制暗化强度。
    let vignette = 1.0 - length(uv - 0.5) * 0.4;

    return vec4f(finalCol * vignette, 1.0);
  }
`

// 统一提取坐标:鼠标事件直接读 clientX/Y,触摸事件读 touches[0]
const getClientPos = (e: MouseEvent | TouchEvent) => {
  if (e instanceof TouchEvent) {
    const t = e.touches[0] ?? e.changedTouches[0]
    return { x: t.clientX, y: t.clientY }
  }
  return { x: (e as MouseEvent).clientX, y: (e as MouseEvent).clientY }
}

const mouseDown = (e: MouseEvent | TouchEvent) => {
  isDragging = true
  const { x, y } = getClientPos(e)
  lastX = x
  lastY = y
}

const mouseMove = (e: MouseEvent | TouchEvent) => {
  if (isDragging) {
    // 移动量乘以灵敏度系数 0.01,转为弧度增量。
    // rotX 限制在 [-1, 1] rad,防止摄像机翻转到球体下方。
    const { x, y } = getClientPos(e)
    camera.rotY += (x - lastX) * 0.01
    camera.rotX += (y - lastY) * 0.01
    camera.rotX = Math.max(-1, Math.min(1, camera.rotX))
    lastX = x
    lastY = y
    autoRotate = false // 手动拖拽时停止自动旋转
  }
}

const mouseUp = () => {
  isDragging = false
}

const mouseClick = () => {
  autoRotate = !autoRotate
}

const isMobile = () => 'ontouchstart' in window

const initCanvasFunc = () => {
  if (isMobile()) {
    canvas.addEventListener('touchstart', mouseDown)
    canvas.addEventListener('touchmove', mouseMove)
    canvas.addEventListener('touchend', mouseUp)
    // touchend 同时当作 click(单指短触)
    canvas.addEventListener('touchend', mouseClick)
  } else {
    canvas.addEventListener('mousedown', mouseDown)
    canvas.addEventListener('mousemove', mouseMove)
    canvas.addEventListener('mouseup', mouseUp)
    canvas.addEventListener('click', mouseClick)
  }
}

const destroyCanvasFunc = () => {
  if (isMobile()) {
    canvas.removeEventListener('touchstart', mouseDown)
    canvas.removeEventListener('touchmove', mouseMove)
    canvas.removeEventListener('touchend', mouseUp)
    canvas.removeEventListener('touchend', mouseClick)
  } else {
    canvas.removeEventListener('mousedown', mouseDown)
    canvas.removeEventListener('mousemove', mouseMove)
    canvas.removeEventListener('mouseup', mouseUp)
    canvas.removeEventListener('click', mouseClick)
  }
}

const onRunning = async () => {
  // ─── 球体几何生成 ────────────────────────────────────────────────────────
  // 为什么用 UV Sphere 而不是 Icosphere?
  // UV Sphere 生成算法更直观(嵌套循环),索引计算简单
  // 适合 demo。Icosphere 顶点分布更均匀,但生成逻辑更复杂
  // 如果 SSGI 演示需要更均匀的全局光照采样,将 createSphere 改为 Icosphere 生成会是一个优化方向
  const createSphere = (position: any, radius: any, color: any, index: any) => {
    // latBands/longBands=32:纬度/经度分段数
    // 32×32 约 4096 个三角形,在不放大的情况下球面足够光滑
    const latBands = 32
    const longBands = 32
    const vertices = []
    const indices = []

    // 遍历每个纬度带,theta 从 0(北极)到 π(南极)
    for (let lat = 0; lat <= latBands; lat++) {
      const theta = (lat * Math.PI) / latBands // 半圈,不是全圈
      const sinTheta = Math.sin(theta)
      const cosTheta = Math.cos(theta)

      // 遍历每个经度带,phi 从 0 到 2π
      for (let lon = 0; lon <= longBands; lon++) {
        const phi = (lon * 2 * Math.PI) / longBands
        const sinPhi = Math.sin(phi)
        const cosPhi = Math.cos(phi)

        // 球面坐标转笛卡尔坐标:
        // x = cos(phi) * sin(theta)(水平分量)
        // y = cos(theta)(垂直分量,±1 为两极)
        // z = sin(phi) * sin(theta)
        const x = cosPhi * sinTheta
        const y = cosTheta
        const z = sinPhi * sinTheta

        // 位置:球面坐标乘半径再加上中心位置
       vertices.push(radius * x, radius * y, radius * z)

        // 法线:单位球面上,法线 = 归一化位置向量,直接复用 (x,y,z)
        // 这样省去额外的法线计算,球面法线天然如此
        vertices.push(x, y, z)
      }
    }

    // 构建索引,将相邻的4个顶点(一个 quad)组成两个三角形
    for (let lat = 0; lat < latBands; lat++) {
      for (let lon = 0; lon < longBands; lon++) {
        // 为什么每纬度有 longBands+1 个顶点(不是 longBands)
        // 最后一个顶点与第一个顶点位置相同,但 UV/index 不同
        // 方便处理纹理坐标接缝(本例无纹理,但保持结构一致性)
        const first = lat * (longBands + 1) + lon
        const second = first + longBands + 1

        // 每个 quad 拆成 2 个逆时针三角形(配合 cullMode:'back' 正面朝外)
        indices.push(first, second, first + 1)
        indices.push(second, second + 1, first + 1)
      }
    }

    // ── 上传顶点数据到 GPU ──
    // mappedAtCreation: true:创建时直接进入 mapped 状态
    // 可立刻写入 CPU 数据,无需等待 mapAsync 的异步回调,是一次性上传的最优路径
    const vertexBuffer = device.createBuffer({
      size: vertices.length * 4, // float32 每个元素 4 字节
      usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
      mappedAtCreation: true
    })
    new Float32Array(vertexBuffer.getMappedRange()).set(vertices)
    vertexBuffer.unmap() // unmap 后 CPU 无法再访问,GPU 取得独占所有权

    // 索引使用 Uint16Array(2 字节/索引),因为顶点数 < 65536(32×32 球体约 1089 顶点)
    // 相比 Uint32 节省 50% 索引缓冲内存,且 GPU 读取更快
    const indexBuffer = device.createBuffer({
      size: indices.length * 2, // uint16 每个元素 2 字节
      usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
      mappedAtCreation: true
    })
    new Uint16Array(indexBuffer.getMappedRange()).set(indices)
    indexBuffer.unmap()

    // ── 每个球体独立的 Uniform Buffer + Bind Group ──
    // 为什么不共享一个全局 uniform buffer?
    // WebGPU 的 writeBuffer 写入被排队到 GPU Queue,但实际执行在 submit 之后
    // 若所有球体写同一个 buffer,提交时 buffer 只保留最后一次写入的数据
    // 所有球体都会用最后一个球的 model/color 渲染——这正是修复前只看到品红球的原因
    // 每球独立 buffer,保证各球数据在 GPU 执行时互不覆盖
    const uBuffer = device.createBuffer({
      // 64 floats × 4 字节 = 256 字节。WebGPU 要求 uniform buffer 偏移对齐到 256 字节
      size: 64 * 4,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
    })
    const uBindGroup = device.createBindGroup({
      layout: scenePipeline.getBindGroupLayout(0),
      entries: [{ binding: 0, resource: { buffer: uBuffer } }]
    })

    return {
      vertexBuffer,
      indexBuffer,
      indexCount: indices.length,
      color,
      position: [...position], // 展开复制,避免共享引用被外部修改
      radius,
      phase: index * Math.PI * 0.4, // 各球浮动动画的时间偏移,用 index×0.4π 均匀错开,使 5 个球不同步地上下浮动,更自然
      uniformBuffer: uBuffer,
      uniformBindGroup: uBindGroup
    }
  }

  // ─── 更新球体 Uniform 数据 ───────────────────────────────────────────────
	// 每帧每个球体调用一次,计算最新的矩阵/光照/时间值并写入该球的 GPU buffer
  const updateUniforms = (sphere: any, viewProj: any, time: any) => {
    // Float32Array 默认初始化为全 0,利用此特性构建矩阵时只需设置非零元素
    const model = new Float32Array(16)
    const normalMatrix = new Float32Array(16)
    const viewProjArr = new Float32Array(viewProj) // 复制一份,避免修改原数组

    // ── 构建 Model 矩阵(列主序,translation-only) ──
    // 为什么是列主序?WGSL mat4x4f 用列主序存储
    // element(row, col) = array[col*4 + row]
    // 对角线元素(缩放=1)索引:[0]=col0row0, [5]=col1row1, [10]=col2row2, [15]=col3row3
    const floatY = Math.sin(time + sphere.phase) * 0.15 // 上下浮动动画
    model[0] = 1
    model[5] = 1
    model[10] = 1
    model[15] = 1
    // 平移向量存在第 4 列:[12]=tx, [13]=ty, [14]=tz(列主序第 4 列从 index 12 开始)
    model[12] = sphere.position[0]
    model[13] = sphere.position[1] + floatY
    model[14] = sphere.position[2]

    // 法线矩阵 = model 矩阵逆转置
    // 因为本 Demo 只有平移(无旋转/非均匀缩放),逆转置 = 单位矩阵,直接设置对角线即可
    // 若加了旋转/缩放,则需要正确计算逆转置,否则法线方向会错误
    normalMatrix[0] = 1
    normalMatrix[5] = 1
    normalMatrix[10] = 1
    normalMatrix[15] = 1

    // 整个 Uniform struct 打包为 64 个 float = 256 字节
    const uniforms = new Float32Array(64)
    uniforms.set(viewProjArr, 0) // viewProj:float[0..15]
    uniforms.set(model, 16) // model:float[16..31]
    uniforms.set(normalMatrix, 32) // normalMatrix:float[32..47]


    // color(vec3f):float[48..50]
    // 为什么 index 48 之后有 padding?
    // WGSL 规定 vec3f 占 16 字节(4 个 float 的对齐),struct 中每个 vec3f 后
    // 需要 1 个 float 的 padding 来对齐到下一个 16 字节边界
    // 不加 padding,GPU 读取后续字段时偏移量错误,光照计算全部出错
    uniforms[48] = sphere.color[0]
    uniforms[49] = sphere.color[1]
    uniforms[50] = sphere.color[2]
    uniforms[51] = 0 // padding,对齐到 float[52]

    // lightPos(vec3f):float[52..54],绕 Y 轴慢速旋转,使光影动态变化
    uniforms[52] = 5 + Math.sin(time) * 2
    uniforms[53] = 8 // 固定高度
    uniforms[54] = 5 + Math.cos(time) * 2
    uniforms[55] = 0 // padding

    // cameraPos(vec3f):float[56..58]
    // 与 getViewProjMatrix 中的摄像机坐标计算方式完全一致,确保高光方向正确
    const camX = Math.sin(camera.rotY) * Math.cos(camera.rotX) * 10
    const camY = Math.sin(camera.rotX) * 10 + 2
    const camZ = Math.cos(camera.rotY) * Math.cos(camera.rotX) * 10
    uniforms[56] = camX
    uniforms[57] = camY
    uniforms[58] = camZ
    uniforms[59] = 0 // padding

    // time(f32):float[60],供 SSGI 采样方向随时间变化
    uniforms[60] = time

    // writeBuffer 是高频 uniform 更新的标准路径,内部直接 DMA 传输到 GPU
    // 写入操作被排入 GPU Queue,在同一帧的 submit 之前完成,保证数据及时可用
    device.queue.writeBuffer(sphere.uniformBuffer, 0, uniforms)
  }

  const initSphere = () => {
    // ── 创建 5 个球体 ──
    const colors = [
      [1.0, 0.2, 0.2], // 红
      [0.2, 1.0, 0.2], // 绿
      [0.2, 0.2, 1.0], // 蓝
      [1.0, 1.0, 0.2], // 黄
      [1.0, 0.2, 1.0] // 品红
    ]

    // 5 个球体分布在不同位置,使 SSGI 颜色溢出效果更明显。
    const positions = [
      [-2.5, 0, 0],
      [2.5, 0, 0],
      [0, 0, -2.5],
      [-1.5, 0, 2.5],
      [1.5, 0, 2.5]
    ]

    for (let i = 0; i < 5; i++) {
      // index i 用于计算 phase(相位偏移),让每个球体浮动动画错开,更自然。
      spheres.push(createSphere(positions[i], 1.0, colors[i], i))
    }
  }

  // ─── 构建 View-Projection 矩阵 ──────────────────────────────────────────
  // 为什么不用 gl-matrix 等库?这是纯 demo,手写矩阵有助于理解每个元素的含义
  const getViewProjMatrix = () => {
    const fov = Math.PI / 4 // 45° 视场角,介于广角(鱼眼感)和长焦(压缩感)之间
    const aspect = canvas.width / canvas.height // 宽高比,根据 canvas 大小计算
    const near = 0.1 // 近裁剪面距离,避免透视变换导致的近裁剪面距离过近,导致场景被裁剪
    const far = 100 // 远裁剪面距离,足够大以包含整个场景

    // ── 透视投影矩阵(列主序,列主序索引 col*4+row) ──
    // ┌ f/aspect   0        0                    0                   ┐
    // │ 0          f        0                    0                   │
    // │ 0          0   (far+near)/(near-far)  (2*far*near)/(near-far)│
    // └ 0          0       -1                    0                   ┘
    const proj = new Float32Array(16)
    // f = 1/tan(fov/2):焦距,将 fov 范围内的 y 坐标映射到 [-1, 1]
    const f = 1 / Math.tan(fov / 2)
    proj[0] = f / aspect // col=0, row=0,x 轴缩放,额外除以宽高比,修正非正方形屏幕的拉伸
    proj[5] = f // col=1, row=1,y 轴缩放
    // WebGPU NDC 深度范围 [0, 1](非 OpenGL 的 [-1, 1]):
    proj[10] = (far + near) / (near - far) // col=2, row=2,z 轴缩放,映射 [near, far] 到 [0, 1]
    proj[11] = -1 // col=2, row=3,透视投影特征,使 w = -z 参与透视除法【透视除法:w_clip = -z_view(使近平面 w > 0)】
    proj[14] = (2 * far * near) / (near - far) // col=3, row=2,z 轴平移,确保 near 平面映射到 0,far 平面映射到 1【深度偏移项】
  
    // ── 视图矩阵(LookAt,列主序,列主序索引 col*4+row) ──
    const view = new Float32Array(16)
    // 摄像机位置:用球面坐标,绕原点的球面上移动,始终朝向 (0,0,0)
    const camX = Math.sin(camera.rotY) * Math.cos(camera.rotX) * 10
    const camY = Math.sin(camera.rotX) * 10 + 2
    const camZ = Math.cos(camera.rotY) * Math.cos(camera.rotX) * 10


    // 在 lookAt 矩阵中的意义:
    //    在相机坐标系构建中:
    // ——————————————————————————————————————————————————————————————————————————————————————
    //    相机朝向(后方)
    //    zAxis = normalize(eye - target)
    // ——————————————————————————————————————————————————————————————————————————————————————
    //    相机右方 ← 叉积【右手四指从 up(向上) 弯向 zAxis(朝后),大拇指朝向 → 右方,这正是需要的 xAxis】
    //    up × zAxis 得到右方向,zAxis × up 得到左方向。lookAt 矩阵需要相机右轴,所以必须是 up × zAxis
    //    xAxis = normalize(up × zAxis)
    // ——————————————————————————————————————————————————————————————————————————————————————
    //    相机上方(重新正交化)
    //    zAxis后方是正,xAxis右方是正 ← 叉积【右手四指从 zAxis(向后) 弯向 xAxis(朝右),大拇指朝向 → 上方,这正是需要的 yAxis】
    //    yAxis = zAxis × xAxis (注意顺序,zAxis × xAxis 得到 yAxis,xAxis × zAxis 得到 -yAxis)
    // ——————————————————————————————————————————————————————————————————————————————————————

    // zAxis = normalize(eye - target):摄像机朝 -z 看,所以 z 轴朝后(eye→target 的反方向)
    const fx = camX, fy = camY, fz = camZ // eye
    const zDist = Math.sqrt(fx * fx + fy * fy + fz * fz)
    const zAxis = [fx / zDist, fy / zDist, fz / zDist]

    // xAxis = cross(worldUp, zAxis):右方向 = 上方向 × 后方向(右手坐标系)
    const up = [0, 1, 0]
    const xAxis = [
      // 这是**向量叉积(Cross Product)**的标准公式
      // 两个向量 a × b 的结果是一个垂直于这两个向量的新向量,公式为:
      // a × b = (
      //   a[1]*b[2] - a[2]*b[1],   // x 分量
      //   a[2]*b[0] - a[0]*b[2],   // y 分量
      //   a[0]*b[1] - a[1]*b[0]    // z 分量
      // )
      up[1] * zAxis[2] - up[2] * zAxis[1],
      up[2] * zAxis[0] - up[0] * zAxis[2],
      up[0] * zAxis[1] - up[1] * zAxis[0]
    ]
    const xDist = Math.sqrt(xAxis[0] * xAxis[0] + xAxis[1] * xAxis[1] + xAxis[2] * xAxis[2])
    const xNorm = [xAxis[0] / xDist, xAxis[1] / xDist, xAxis[2] / xDist]
  
    // yAxis = cross(zAxis, xNorm):重新计算正交的上方向(Gram-Schmidt 正交化)
    // 不直接用 up[0,1,0],因为 up 可能与 zAxis 不完全垂直
    const yAxis = [
      zAxis[1] * xNorm[2] - zAxis[2] * xNorm[1],
      zAxis[2] * xNorm[0] - zAxis[0] * xNorm[2],
      zAxis[0] * xNorm[1] - zAxis[1] * xNorm[0]
    ]
  
    // 视图矩阵 = [R | -R*eye](旋转部分按列存储,平移部分是 -dot(axis, eye))
    view[0] = xNorm[0]
    view[1] = yAxis[0]
    view[2] = zAxis[0]
    view[4] = xNorm[1]
    view[5] = yAxis[1]
    view[6] = zAxis[1]
    view[8] = xNorm[2]
    view[9] = yAxis[2]
    view[10] = zAxis[2]
    // 平移分量:将世界坐标系的原点移到摄像机位置处(反向平移)
    view[12] = -(xNorm[0] * camX + xNorm[1] * camY + xNorm[2] * camZ)
    view[13] = -(yAxis[0] * camX + yAxis[1] * camY + yAxis[2] * camZ)
    view[14] = -(zAxis[0] * camX + zAxis[1] * camY + zAxis[2] * camZ)
    view[15] = 1


    // ── proj × view(列主序矩阵乘法) ──
    // 为什么 proj[k*4+row] * view[col*4+k]?
    // 列主序:element(row, col) = array[col*4 + row]
    // 矩阵乘法 C[col][row] = ΣA[k][row] * B[col][k]
    //                     = Σproj[k*4+row] * view[col*4+k]
    const viewProj = new Float32Array(16)
    for (let col = 0; col < 4; col++) {
      for (let row = 0; row < 4; row++) {
        let sum = 0
        for (let k = 0; k < 4; k++) {
          sum += proj[k * 4 + row] * view[col * 4 + k]
        }
        viewProj[col * 4 + row] = sum
      }
    }

    return viewProj
  }


  // ─── WebGPU 初始化 ──────────────────────────────────────────────────────
  const initWebGPU = async () => {
    canvas = document.getElementById('ssgi')

    const wrapDomStyle = getComputedStyle(canvas)
    const width = parseInt(wrapDomStyle.width, 10)
    const height = parseInt(wrapDomStyle.height, 10)
    // 需要设置canvas的width和height
    canvas.width = width
    canvas.height = height

    if (!(navigator as any).gpu) {
      alert('不支持WebGPU。请使用Chrome 113或更高版本或Edge 113或更高版本')
      return
    }

    const adapter = await (navigator as any).gpu.requestAdapter()

    if (!adapter) {
      alert('未能获取WebGPU适配器')
      return
    }

    device = await adapter.requestDevice()
    context = canvas.getContext('webgpu')
    console.log(canvas)

    // getPreferredCanvasFormat:不同平台偏好不同格式(bgra8unorm / rgba8unorm)
    // 使用首选格式可避免 GPU 内部做额外的颜色格式转换,提升性能
    const format = (navigator as any).gpu.getPreferredCanvasFormat()
    context.configure({
      device: device,
      format: format,
      // premultiplied:标准 alpha 预乘模式,避免半透明边缘出现暗边(黑边伪影)
      alphaMode: 'premultiplied'
    })

    // ── 编译 WGSL 着色器 ──
    // createShaderModule 将 WGSL 字符串编译为 GPU 可执行的着色器模块
    // 异步编译在后台进行,不阻塞 JS 主线程

    const sceneVSShader = device.createShaderModule({ code: sceneVS })
    const sceneFSShader = device.createShaderModule({ code: sceneFS })
    const postVSShader = device.createShaderModule({ code: postVS })
    const postFSShader = device.createShaderModule({ code: postFS })

    // ── 场景渲染管线(scenePipeline) ──
    scenePipeline = device.createRenderPipeline({
      // layout: 'auto':让 WebGPU 从着色器代码自动推断 bind group layout
      // 省去手动声明 GPUBindGroupLayout 的样板代码,适合 demo 场景
      layout: 'auto',
      vertex:{
        module: sceneVSShader,
        entryPoint: 'main',
        buffers: [{
          // arrayStride=24:每个顶点占 24 字节(位置 xyz + 法线 xyz,各 3×4)
          // GPU 按此步长在 vertex buffer 中逐顶点取数据
          arrayStride: 24,
          attributes: [
            // shaderLocation 对应 WGSL 里 @location(N)
            { shaderLocation: 0, offset: 0, format: 'float32x3' }, // position
            { shaderLocation: 1, offset: 12, format: 'float32x3' } // normal
          ]
        }]
      },
      fragment: {
        module: sceneFSShader,
        entryPoint: 'main',
        // 为什么输出到 rgba16float 而不是直接输出到 canvas?
        // rgba16float 是 HDR 格式,可存储 >1.0 的高光值,不会截断
        // 后处理 Pass 再读这张 HDR 纹理做 tone mapping,最后输出到 canvas
        targets: [{ format: 'rgba16float' }]
      },
      primitive: {
        topology: 'triangle-list',
        // cullMode: 'back':剔除背面三角形(法线朝内的面不可见)
        // 减少约 50% 的片元着色器调用,是标准性能优化
        cullMode: 'back'
      },
      depthStencil: {
        depthWriteEnabled: true,
        // 'less':新片元深度 < 已有深度时才通过测试(近处遮挡远处)。
        depthCompare: 'less',
        // depth24plus:24 位深度,足够精度;+stencil 可选,这里只用深度。
        format: 'depth24plus'
      }
    })

    // ── 后处理管线(postPipeline) ──
    // 无深度测试(全屏后处理不需要 3D 深度比较),无 vertex buffer(顶点靠 index 生成)
    postPipeline = device.createRenderPipeline({
      layout: 'auto',
      vertex: {
        module: postVSShader,
        entryPoint: 'main'
      },
      fragment: {
        module: postFSShader,
        entryPoint: 'main',
        // 最终输出到 canvas 的格式(bgra8unorm 或 rgba8unorm)
        targets: [{ format }]
      },
      primitive: {
        topology: 'triangle-list'
      }
    })

    // sceneTexture:场景渲染的中间 HDR 缓冲
    // RENDER_ATTACHMENT:可作为 render pass 的颜色输出目标
    // TEXTURE_BINDING:可在后处理着色器中被采样读取。两个 flag 都需要
    sceneTexture = device.createTexture({
      size: [canvas.width, canvas.height],
      format: 'rgba16float',
      usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
    })

    // depthTexture:深度缓冲,记录每像素最近片元的深度,实现遮挡关系
    // 只需 RENDER_ATTACHMENT,不需要在着色器中采样(如需 SSAO 则要加 TEXTURE_BINDING)
    depthTexture = device.createTexture({
      size: [canvas.width, canvas.height],
      format: 'depth24plus',
      usage: GPUTextureUsage.RENDER_ATTACHMENT
    })

    // ── 后处理 Bind Group ──
    // 将 sceneTexture 和 sampler 绑定到后处理着色器的 @binding(0)/@binding(1)
    // resize 时 sceneTexture 被销毁重建,所以 postBindGroup 也需要重建(见 resize 事件)
    postBindGroup = device.createBindGroup({
      layout: postPipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: sceneTexture.createView() },
        // linear filter:双线性插值采样,模糊边界处更平滑,适合后处理。
        { binding: 1, resource: device.createSampler({ magFilter: 'linear', minFilter: 'linear' }) }
      ]
    })

    initSphere()

    initCanvasFunc()

    render()
  }

  // ─── 主渲染循环 ─────────────────────────────────────────────────────────
  const render = () => {
    // time 以固定步长递增(约 60fps 帧时长 1/60≈0.016s)。
    // 简化实现;精确版应用 requestAnimationFrame 回调的 timestamp 参数计算 deltaTime。
    time += 0.016

    if (autoRotate) {
      camera.rotY += 0.005 // 自动慢速水平旋转,展示各球体受光情况
    }

    const viewProj = getViewProjMatrix()

    // ── Command Encoder:记录本帧所有 GPU 命令 ──
    // WebGPU 是显式命令提交模型:先录制命令(CPU 侧),再一次性 submit 给 GPU。
    // 好处:驱动可以批量优化命令流;与 WebGL 每条调用立即发送的模式不同。
    const commandEncoder = device.createCommandEncoder()

    // ═══════════════════════════════════════════════════════
    // Pass 1:场景渲染 → sceneTexture(HDR 中间缓冲)
    // ═══════════════════════════════════════════════════════
    const scenePass = commandEncoder.beginRenderPass({
      colorAttachments: [{
        view: sceneTexture.createView(),
        // 每帧先清为深色背景(接近黑色带微蓝,营造夜间场景感)
        clearValue: { r: 0.02, g: 0.02, b: 0.05, a: 1 },
        loadOp: 'clear', // 渲染前清除,确保上一帧残留不影响本帧
        storeOp: 'store' // 渲染完保留结果,供后处理 Pass 读取
      }],
      depthStencilAttachment: {
        view: depthTexture.createView(),
        depthClearValue: 1.0, // 1.0 = 最远深度,所有新片元初始都能通过 'less' 测试
        depthLoadOp: 'clear',
        depthStoreOp: 'store' // 深度值本帧内复用(若有多个物体前后遮挡)
      }
    })

    scenePass.setPipeline(scenePipeline)

    // 逐球渲染:每个球体写自己的 uniformBuffer → 绑定自己的 bindGroup → 绘制。
    // 因为 writeBuffer 在 submit 前全部执行完毕,独立 buffer 保证各球数据不互相覆盖。
    spheres.forEach(sphere => {
      updateUniforms(sphere, viewProj, time)
      scenePass.setBindGroup(0, sphere.uniformBindGroup)
      scenePass.setVertexBuffer(0, sphere.vertexBuffer)
      scenePass.setIndexBuffer(sphere.indexBuffer, 'uint16')
      scenePass.drawIndexed(sphere.indexCount)
    })

    scenePass.end()

    // ═══════════════════════════════════════════════════════
    // Pass 2:后处理(双边模糊 + 晕影)→ Canvas 显示缓冲
    // ═══════════════════════════════════════════════════════
    const postPass = commandEncoder.beginRenderPass({
      colorAttachments: [{
        // getCurrentTexture():从 swapchain 取当前帧的后缓冲区(双缓冲/三缓冲之一)。
        // 每帧调用以确保写入正确的缓冲,不能缓存其 view(swapchain 会轮转)。
        view: context.getCurrentTexture().createView(),
        clearValue: { r: 0, g: 0, b: 0, a: 1 },
        loadOp: 'clear',
        storeOp: 'store'
      }]
      // 无 depthStencilAttachment:全屏后处理不需要深度测试
    })

    postPass.setPipeline(postPipeline)
    postPass.setBindGroup(0, postBindGroup) // sceneTexture + sampler
    postPass.draw(6) // 6 个顶点 = 2 个三角形 = 覆盖全屏的四边形
    postPass.end()

    // finish() 封闭命令缓冲区,submit() 提交给 GPU Queue 异步执行。
    // 两步分离允许同时提交多个 commandBuffer(本例只有一个)。
    device.queue.submit([commandEncoder.finish()])

    // 注册下一帧,形成持续渲染循环。
    // RAF 在浏览器绘制前触发,自动匹配显示器刷新率(60/120/144Hz 均适用)。
    requestID.value = requestAnimationFrame(render)
  }

  initWebGPU()
}

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

const destroy = () => {
  cancelAnimationFrame(requestID.value)
  requestID.value = null

  destroyCanvasFunc()

  // 释放每个球体占用的 GPU 缓冲区
  spheres.forEach(sphere => {
    sphere.vertexBuffer.destroy()
    sphere.indexBuffer.destroy()
    sphere.uniformBuffer.destroy()
  })
  spheres = []

  // 释放离屏纹理(显存)
  sceneTexture?.destroy()
  depthTexture?.destroy()
  sceneTexture = null
  depthTexture = null

  // 断开 canvas 与 WebGPU 的关联,释放 swapchain
  context?.unconfigure()
  context = null

  // 销毁 GPUDevice,释放所有设备级资源(pipeline、shader 等由 device 统一回收)
  device?.destroy()
  device = null

  // 清空管线引用(pipeline 对象由 device 销毁后自动失效,这里只是置空 JS 引用)
  scenePipeline = null
  postPipeline = null
  postBindGroup = null

  canvas = null
}

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

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