Skip to content

Wireframe cube

Download PNG

Twelve edges, twenty-four vertices, one draw call with line-list topology: every pair of vertices becomes one line, and the vertex shader does the rotation and a hand-written perspective divide. Lines further from the camera are dimmer. There is no depth buffer and no culling; a wireframe does not need either.

examples/samples/05_wireframe_cube.sjon
; Wireframe cube: a rotating cube drawn as 12 line segments with `line-list`
; topology; brightness falls off with depth. Exercises a float32 vertex buffer
; consumed as line pairs and a pngine-inputs uniform.
(shader-module :name shader :code """
struct Uniforms {
time: f32,
width: f32,
height: f32,
aspect: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) depth: f32,
}
fn rotateY(p: vec3f, angle: f32) -> vec3f {
let c = cos(angle);
let s = sin(angle);
return vec3f(p.x * c + p.z * s, p.y, -p.x * s + p.z * c);
}
fn rotateX(p: vec3f, angle: f32) -> vec3f {
let c = cos(angle);
let s = sin(angle);
return vec3f(p.x, p.y * c - p.z * s, p.y * s + p.z * c);
}
@vertex
fn vs(@location(0) pos: vec3f) -> VertexOutput {
let t = u.time;
// Scale and rotate
var p = pos * 0.3;
p = rotateY(p, t * 0.5);
p = rotateX(p, t * 0.3);
// Simple perspective
let z = p.z + 2.5;
let projX = p.x / z / u.aspect;
let projY = p.y / z;
var out: VertexOutput;
out.pos = vec4f(projX, projY, 0.5, 1.0);
out.depth = z;
return out;
}
@fragment
fn fs(in: VertexOutput) -> @location(0) vec4f {
// Depth-based brightness
let brightness = 1.0 - (in.depth - 2.0) * 0.3;
let color = vec3f(0.2, 0.8, 1.0) * brightness;
return vec4f(color, 1.0);
}
""")
; Cube edges as line segments (12 edges × 2 verts = 24 vertices, stride 12).
(data :name lineVertices :float32 [
-1 -1 -1 1 -1 -1
1 -1 -1 1 -1 1
1 -1 1 -1 -1 1
-1 -1 1 -1 -1 -1
-1 1 -1 1 1 -1
1 1 -1 1 1 1
1 1 1 -1 1 1
-1 1 1 -1 1 -1
-1 -1 -1 -1 1 -1
1 -1 -1 1 1 -1
1 -1 1 1 1 1
-1 -1 1 -1 1 1
])
(buffer :name vertexBuffer :usage [vertex]
:data lineVertices)
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms
(write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(render-pipeline :name pipeline
:layout auto
(vertex :module shader :entry vs
(vertex-buffer :array-stride 12
(attribute :shader-location 0 :offset 0 :format float32x3)))
(fragment :module shader :entry fs
(target :format preferred-canvas-format))
(primitive :topology line-list))
(bind-group :name uniformsBindGroup :layout pipeline :group 0
(entry :binding 0 :buffer uniforms))
(render-pass :name mainPass
(color-attachment :view context-current-texture :clear-value [0.05 0.05 0.1 1] :load-op clear :store-op store)
:pipeline pipeline
:vertex-buffers [vertexBuffer]
:bind-groups [uniformsBindGroup]
(draw :vertex-count 24))
(frame :name main :perform [writeUniforms mainPass])

The document is SJON, the S-expression format pngine compiles: each form is one WebGPU resource or operation, and the shader text inside (shader-module …) is plain WGSL, WebGPU’s shading language.

(data :name lineVertices :float32 [ … ]) lists the cube’s twelve edges as pairs of endpoints, x y z each, in the [-1, 1] cube: four edges of the bottom face, four of the top, four verticals. (buffer :name vertexBuffer :usage [vertex] :data lineVertices) uploads them at creation, :data giving the buffer both its contents and its size, and the pipeline’s (vertex-buffer :array-stride 12 (attribute :shader-location 0 :offset 0 :format float32x3)) reads one vec3f per vertex.

(primitive :topology line-list) is the one line that changes the picture: with it, (draw :vertex-count 24) assembles vertices (0,1) (2,3) … (22,23) into twelve independent lines instead of eight triangles. WebGPU rasterizes lines one pixel wide; there is no line width setting in the API, which is why the wireframe stays thin at any resolution.

vs scales the cube to 0.3, rotates it about y then x with the time uniform, and projects with a manual perspective: z + 2.5 moves the cube in front of the camera, x / z / aspect and y / z divide by depth. Writing aspect into the divide is what keeps the cube square on the wide canvas above. pos.z is written as a constant 0.5, which is fine because nothing depth-tests. The pre-projection depth travels to fs as @location(0) and drives brightness = 1 - (depth - 2) * 0.3, so the far edges fade.

The uniform buffer refilled every frame from pngine-inputs (the built-in time/width/height/aspect source), the bind group, the clear-and-draw pass and the frame are the scaffold from Gradient background.

What the sample uses WebGPU WGSL
Line-list topology primitive state, "line-list", primitive assembly, line rasterization
Vertex buffer with one float32x3 attribute GPUVertexBufferLayout, "float32x3", mappedAtCreation @location inputs
Drawing 24 vertices draw(), setVertexBuffer()
Clip-space output and the perspective divide clip space coordinates, coordinate systems position (vertex output)
Depth-based shading @location outputs, interpolation, sin / cos