A texture array is one texture with several layers of the same size and format, selected by an integer in the shader (texture_2d_array), so one binding serves many images, and unlike an atlas, mipmapping never bleeds one image into its neighbor. Cover Texture Array builds one. A cube map is an array of six square layers seen through a 'cube' view (texture_cube) and sampled with a direction vector whose largest component picks the face; the vector's length does not matter. Layers are stored in the order +X, -X, +Y, -Y, +Z, -Z. A compute shader that wrote each face's layer number into a 1 x 1 cube and sampled the directions (0.9, 0.2, -0.3), (0, 1, 0.1) and (0.2, -0.3, -2) got back the +X, +Y and -Z faces:
@group(0) @binding(0) var sky: texture_cube<f32>; // view: { dimension: 'cube' }
@group(0) @binding(1) var smp: sampler;
fn skyColor(direction: vec3f) -> vec4f {
return textureSampleLevel(sky, smp, direction, 0); // explicit level: any stage
}Skyboxes, reflections and point-light shadows use cube maps. Create the texture with size: [n, n, 6], upload each face with origin: [0, 0, face], and bind a view with dimension: 'cube'.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.stage { position: relative; width: 100%; max-width: 600px; }
.stage canvas { display: block; width: 100%; }
.stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
<canvas id="view" width="600" height="330"></canvas>
<canvas id="labels" width="600" height="330"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
const faces = [['+X', '#c0392b'], ['-X', '#e67e22'], ['+Y', '#27ae60'], ['-Y', '#16a085'], ['+Z', '#2e6bc4'], ['-Z', '#8e44ad']];
const code = /* wgsl */ `
@group(0) @binding(0) var sky: texture_cube<f32>; // view: { dimension: 'cube' }
@group(0) @binding(1) var layers: texture_2d_array<f32>; // the same texture as an array
@group(0) @binding(2) var smp: sampler;
@group(0) @binding(3) var<uniform> time: f32;
fn skyColor(direction: vec3f) -> vec4f {
return textureSampleLevel(sky, smp, direction, 0); // the largest component picks the face
}
struct Out { @builtin(position) pos: vec4f, @location(0) dir: vec3f }
@vertex fn cubeVs(@builtin(vertex_index) v: u32) -> Out {
let face = v / 6;
let q = array(vec2f(-1, -1), vec2f(1, -1), vec2f(1, 1), vec2f(-1, -1), vec2f(1, 1), vec2f(-1, 1))[v % 6];
// Per face: the outward normal, then two in-plane axes.
let n = array(vec3f(1, 0, 0), vec3f(-1, 0, 0), vec3f(0, 1, 0), vec3f(0, -1, 0), vec3f(0, 0, 1), vec3f(0, 0, -1))[face];
let t = array(vec3f(0, 0, -1), vec3f(0, 0, 1), vec3f(1, 0, 0), vec3f(1, 0, 0), vec3f(1, 0, 0), vec3f(-1, 0, 0))[face];
let b = array(vec3f(0, 1, 0), vec3f(0, 1, 0), vec3f(0, 0, -1), vec3f(0, 0, 1), vec3f(0, 1, 0), vec3f(0, 1, 0))[face];
let p = n + t * q.x + b * q.y; // a cube corner: also its sampling direction
let a = time; let tilt = 0.5;
var r = vec3f(p.x * cos(a) + p.z * sin(a), p.y, -p.x * sin(a) + p.z * cos(a));
r = vec3f(r.x, r.y * cos(tilt) - r.z * sin(tilt), r.y * sin(tilt) + r.z * cos(tilt));
let z = r.z + 4.5;
return Out(vec4f(r.x * 1.6 / 1.8, r.y * 1.6, (z - 2) / 5 * z, z), p); // depth 0..1 after the divide
}
@fragment fn cubeFs(in: Out) -> @location(0) vec4f { return skyColor(in.dir); }
struct Flat { @builtin(position) pos: vec4f, @location(0) uv: vec2f, @location(1) @interpolate(flat) layer: u32 }
@vertex fn stripVs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Flat {
let q = vec2f(f32(v & 1), f32(v >> 1));
return Flat(vec4f(0.2 + f32(i % 3) * 0.26 + q.x * 0.24, 0.05 - f32(i / 3) * 0.58 + q.y * 0.42, 0, 1), vec2f(q.x, 1 - q.y), i);
}
@fragment fn stripFs(in: Flat) -> @location(0) vec4f { return textureSampleLevel(layers, smp, in.uv, in.layer, 0); }`;
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return showMessage('WebGPU is not available in this browser');
const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
// size: [n, n, 6]; each face uploaded with origin: [0, 0, face], in the order +X, -X, +Y, -Y, +Z, -Z.
const N = 128;
const cube = device.createTexture({ size: [N, N, 6], format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT });
faces.forEach(([name, color], face) => {
const art = new OffscreenCanvas(N, N), g = art.getContext('2d');
g.fillStyle = color; g.fillRect(0, 0, N, N);
g.strokeStyle = 'rgba(255,255,255,0.6)'; g.lineWidth = 6; g.strokeRect(3, 3, N - 6, N - 6);
g.fillStyle = '#fff'; g.font = 'bold 48px sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(name, N / 2, N / 2);
device.queue.copyExternalImageToTexture({ source: art }, { texture: cube, origin: [0, 0, face] }, [N, N]);
});
const module = device.createShaderModule({ code });
const depth = { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' };
const cubePipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module, entryPoint: 'cubeVs' },
fragment: { module, entryPoint: 'cubeFs', targets: [{ format }] }, depthStencil: depth, primitive: { cullMode: 'none' } });
const stripPipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module, entryPoint: 'stripVs' }, fragment: { module, entryPoint: 'stripFs', targets: [{ format }] }, depthStencil: depth });
const time = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear' });
const cubeGroup = device.createBindGroup({ layout: cubePipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: cube.createView({ dimension: 'cube' }) }, { binding: 2, resource: sampler }, { binding: 3, resource: { buffer: time } }] });
const stripGroup = device.createBindGroup({ layout: stripPipeline.getBindGroupLayout(0), entries: [
{ binding: 1, resource: cube.createView({ dimension: '2d-array' }) }, { binding: 2, resource: sampler }] });
const depthTexture = device.createTexture({ size: [600, 330], format: 'depth24plus', usage: GPUTextureUsage.RENDER_ATTACHMENT });
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText("texture_cube: sampled with a direction", 12, 20);
ink.fillText("the same 6 layers as '2d-array'", 366, 20);
ink.font = '11px system-ui, sans-serif'; ink.fillStyle = '#555';
faces.forEach(([name], i) => ink.fillText(`layer ${i}`, 366 + (i % 3) * 78, 170 + Math.floor(i / 3) * 96));
function frame(now) {
device.queue.writeBuffer(time, 0, new Float32Array([now / 1400]));
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{ view: context.getCurrentTexture().createView(), clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }],
depthStencilAttachment: { view: depthTexture.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'discard' } });
pass.setViewport(0, 20, 340, 300, 0, 1);
pass.setPipeline(cubePipeline); pass.setBindGroup(0, cubeGroup); pass.draw(36);
pass.setViewport(0, 0, 600, 330, 0, 1);
pass.setPipeline(stripPipeline); pass.setBindGroup(0, stripGroup); pass.draw(4, 6);
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>