iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Software Development

渲染與GPU編程系列 第 22

Day 21|WebGPU 實作「基本光照與材質」

  • 分享至 

  • xImage
  •  

這一篇把你的 WebGPU 畫面從「只有顏色」升級到「有光、有材質貼圖」。
我們會做三件事:

  1. 建立 Uniform(相機/光/材質參數),
  2. 建立 材質貼圖 + 取樣器(sampler)
  3. WGSL 實作 Lambert + Blinn-Phong 基本打光,並把貼圖當作表面顏色(albedo)。

先假設你已經有上一章的「初始化 & 清畫面」和「載入模型」骨架(頂點含 位置/法線/UV,索引可選)。
沒有模型也行,你可以先用一個方塊/平面測試。


1)觀念暖身:我們到底要算什麼?

  • 材質(albedo):表面的「底色」。通常來自一張貼圖(texture),或一個顏色係數(baseColorFactor)。

  • 光照(這篇用經典 Blinn-Phong)

    • Ambient:環境光(避免全黑)。
    • Diffuse(Lambert)max(dot(N, L), 0),表面面向光線越正,越亮。
    • Specular(高光):用 半角向量 H = normalize(L + V),高光強度約 pow(max(dot(N, H), 0), shininess)
  • 相機/矩陣:把物件位置轉到螢幕上(MVP),把法線轉到正確空間(normal matrix)。


2)我們會準備哪些 GPU 資源?

  • Uniform Buffer:裝 MVPnormalMatlightDircameraPosbaseColorFactorshininess
  • Texture2D + Sampler:albedo 貼圖與取樣器(要不要線性過濾、超出 UV 怎麼處理)。
  • Vertex/Index Buffer:模型幾何(位置/法線/UV)。
  • RenderPipeline:VS/FS、頂點格式、深度測試、輸出格式。

3)WGSL(著色器)——基本光 + 材質

檔名 shader.wgsl
你需要在 VS 輸出世界空間法線與 UV,FS 取樣貼圖並計算光照。

// ---------- Uniforms(全部 16 bytes 對齊,避免對齊地雷) ----------
struct Uniforms {
  mvp             : mat4x4<f32>,
  normalMat       : mat4x4<f32>,
  lightDir        : vec4<f32>, // xyz 有效
  cameraPos       : vec4<f32>,
  baseColorFactor : vec4<f32>, // 乘在貼圖上的顏色
  matParams       : vec4<f32> // x = shininess,其餘保留
};
@group(0) @binding(0) var<uniform> U : Uniforms;

// 材質貼圖與取樣器
@group(0) @binding(1) var colorTex : texture_2d<f32>;
@group(0) @binding(2) var colorSmp : sampler;

// VS 輸出結構
struct VSOut {
  @builtin(position) pos : vec4<f32>,
  @location(0) normalWS  : vec3<f32>,
  @location(1) uv        : vec2<f32>,
  @location(2) posWS     : vec3<f32>
};

@vertex
fn vs_main(@location(0) inPos: vec3<f32>,
           @location(1) inNor: vec3<f32>,
           @location(2) inUV : vec2<f32>) -> VSOut {
  var o: VSOut;
  o.pos = U.mvp * vec4<f32>(inPos, 1.0);

  // 法線轉到世界空間(w=0 不受平移)
  o.normalWS = normalize((U.normalMat * vec4<f32>(inNor, 0.0)).xyz);

  o.uv    = inUV;
  o.posWS = (U.normalMat * vec4<f32>(inPos, 1.0)).xyz; // 簡便:若 normalMat=模型矩陣,posWS 也能近似
  return o;
}

@fragment
fn fs_main(@location(0) N_in : vec3<f32>,
           @location(1) uv    : vec2<f32>,
           @location(2) posWS : vec3<f32>) -> @location(0) vec4<f32> {
  // 取樣 albedo(先不處理 gamma,初學版本夠用)
  let albedoTex = textureSample(colorTex, colorSmp, uv).rgb;
  let albedo = albedoTex * U.baseColorFactor.rgb;

  // 向量:法線/光/視線/半角
  let N = normalize(N_in);
  let L = normalize(U.lightDir.xyz);
  let V = normalize(U.cameraPos.xyz - posWS);
  let H = normalize(L + V);

  // 光照
  let ambient  = 0.08;
  let diffuse  = max(dot(N, L), 0.0);
  let shininess = max(U.matParams.x, 1.0);
  let specular = pow(max(dot(N, H), 0.0), shininess);

  // 混合(係數可自行微調)
  let kd = 1.0;      // 漫反射權重
  let ks = 0.25;     // 高光權重
  let color = albedo * (ambient + kd * diffuse) + vec3<f32>(ks * specular);

  return vec4<f32>(color, 1.0);
}

之後想更真實,你可以:

  • 對貼圖做 sRGB→Linearpow(albedoTex, 2.2))再計算;最後輸出再 pow(color, 1.0/2.2)
  • 加上 法線貼圖金屬粗糙度 等(屬於 PBR 範圍)。

4)JavaScript / TypeScript——把資源準備齊

下面程式碼可以直接接在你現有的 main.ts / main.js 裡(初始化過 device/context/format、已有 vbuf/ibuf)。
頂點格式:arrayStride = 32 bytes,offset 分別是 0/12/24 對應 3/3/2 個 float。

// 0) 已有:device、context、format、canvas、頂點/索引緩衝 vbuf/ibuf、深度貼圖 depthView
//    若還沒有,請參考你前幾篇的初始化與載入模型教學。

// 1) 建 Uniform Buffer(大小 = mvp 64 + normal 64 + 4*vec4 64 = 192 bytes)
const UBO_SIZE = 64 + 64 + 16 * 4; // 192
const ubo = device.createBuffer({
  size: UBO_SIZE,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});

// 2) 載入貼圖並建立 Texture + Sampler
async function loadTexture(url: string) {
  const bmp = await createImageBitmap(await (await fetch(url)).blob());
  const tex = device.createTexture({
    size: [bmp.width, bmp.height],
    format: 'rgba8unorm',
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
  });
  device.queue.copyExternalImageToTexture(
    { source: bmp },
    { texture: tex },
    { width: bmp.width, height: bmp.height }
  );
  const sampler = device.createSampler({
    magFilter: 'linear',
    minFilter: 'linear',
    mipmapFilter: 'linear',
    addressModeU: 'repeat',
    addressModeV: 'repeat'
  });
  return { tex, sampler };
}
const { tex: colorTex, sampler: colorSmp } = await loadTexture('assets/albedo.png');

// 3) BindGroupLayout / PipelineLayout / BindGroup
const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' }},
    { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' }},
    { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' }},
  ]
});
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });
const bindGroup = device.createBindGroup({
  layout: bindGroupLayout,
  entries: [
    { binding: 0, resource: { buffer: ubo }},
    { binding: 1, resource: colorTex.createView() },
    { binding: 2, resource: colorSmp },
  ]
});

// 4) 讀入 WGSL + 建管線(記得頂點格式對齊你的資料)
const shaderModule = device.createShaderModule({ code: await (await fetch('shader.wgsl')).text() });

const vertexLayout: GPUVertexBufferLayout = {
  arrayStride: 8 * 4, // 32 bytes
  attributes: [
    { shaderLocation: 0, offset: 0,      format: 'float32x3' }, // position
    { shaderLocation: 1, offset: 3 * 4,  format: 'float32x3' }, // normal
    { shaderLocation: 2, offset: 6 * 4,  format: 'float32x2' }, // uv
  ]
};

const pipeline = device.createRenderPipeline({
  layout: pipelineLayout,
  vertex: { module: shaderModule, entryPoint: 'vs_main', buffers: [vertexLayout] },
  primitive: { topology: 'triangle-list', cullMode: 'back', frontFace: 'ccw' },
  fragment: { module: shaderModule, entryPoint: 'fs_main', targets: [{ format }] },
});

// 5) 每幀更新 Uniform(相機 + 模型 + 光 + 材質)
function updateUBO(timeSec: number) {
  // —— 這裡用你慣用的數學庫;示意就地計算 —— //
  const eye = new Float32Array([0, 0.7, 2.2]);
  const center = new Float32Array([0, 0.2, 0]);
  const up = new Float32Array([0, 1, 0]);

  // 請用你現有的矩陣工具產生 proj/view/model(或沿用前幾篇的小工具)
  const proj = perspective(60 * Math.PI / 180, canvas.width / canvas.height, 0.1, 100);
  proj[5] *= -1; // WebGPU NDC 的 Y 翻轉
  const view  = lookAt(eye, center, up);
  const model = rotateY(timeSec * 0.6);

  const mvp = mul(proj, mul(view, model));
  const normalMat = model; // 入門:僅旋轉/等比縮放時可直接用 model;一般應使用「逆轉置」

  const lightDir = new Float32Array([-0.4, -1.0, -0.35, 0]);
  const cameraPos = new Float32Array([eye[0], eye[1], eye[2], 1]);
  const baseColorFactor = new Float32Array([1.0, 1.0, 1.0, 1.0]);
  const matParams = new Float32Array([64.0, 0, 0, 0]); // shininess = 64

  // 依照 WGSL 的排列寫入(位移都以 16 bytes 對齊)
  device.queue.writeBuffer(ubo,   0, mvp.buffer ?? mvp);          // 0..63
  device.queue.writeBuffer(ubo,  64, normalMat.buffer ?? normalMat); // 64..127
  device.queue.writeBuffer(ubo, 128, lightDir);                   // 128..143
  device.queue.writeBuffer(ubo, 144, cameraPos);                  // 144..159
  device.queue.writeBuffer(ubo, 160, baseColorFactor);            // 160..175
  device.queue.writeBuffer(ubo, 176, matParams);                  // 176..191
}

// 6) 繪製流程(和你之前的一樣,換成 setBindGroup + drawIndexed)
function frame(ts: number) {
  const t = ts * 0.001;
  updateUBO(t);

  const encoder = device.createCommandEncoder();
  const colorView = context.getCurrentTexture().createView();

  const pass = encoder.beginRenderPass({
    colorAttachments: [{ view: colorView, clearValue: { r:0.06, g:0.10, b:0.16, a:1 }, loadOp:'clear', storeOp:'store' }],

  });

  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.setVertexBuffer(0, vbuf);
  if (ibuf) { pass.setIndexBuffer(ibuf, 'uint32'); pass.drawIndexed(indexCount); }
  else      { pass.draw(vertexCount); }
  pass.end();

  device.queue.submit([encoder.finish()]);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

// —— 你自己的矩陣函式(perspective/lookAt/rotateY/mul)請沿用前文或任何工具 —— //

小提醒

  • BindGroup 的順序與 WGSL 的 @binding() 必須完全一致
  • 頂點格式:務必檢查 arrayStride/offset/format 跟你的資料一致(本例 32 bytes)。

成果:
result

JS 程式碼:https://pastecode.io/s/2gmcwo7x


一句話總結

基本光照與材質 = 「貼圖(albedo) ×(環境 + 漫反射 + 高光)」
Uniform(相機/光/材質)說清楚、把 Texture+Sampler 綁上去、在 WGSL 做簡單的光照公式,你的模型就從「純色」變成「有質感」的 3D 物件了。
接下來加法線貼圖與 PBR,你就能走向更真實、更穩定的材質表現!


上一篇
Day 20|在 WebGPU 中載入並繪製模型
系列文
渲染與GPU編程22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言