04 - The Synthwave Cube
The first 3D-geometry rung. After three fullscreen-fragment tutorials, this
one draws an actual mesh: a textured, lit, breathing cube spinning while the
camera orbits it. It introduces everything 01-03 skipped – a real vertex buffer
with per-vertex (pos, normal, uv), the model / view / projection matrix chain
(mat4 uniforms built host-side with daslib/math_boost), depth testing +
back-face culling, and a texture sampled in the fragment shader.
It is a faithful port of the dasVulkan rung, with the same look: the cube wears a procedural synthwave horizon texture – a deep-purple-to-magenta sky with a banded neon sun, a magenta horizon line, and a dark-indigo ground with a cyan perspective grid – generated entirely CPU-side at init (no asset file). The fragment scrolls the UV vertically over time so the texture drifts like a CRT, and a key + rim light pair shades it.
var @in @location a_pos : float3
var @in @location a_normal : float3
var @in @location a_uv : float2
var @uniform u_model : float4x4
var @uniform u_view : float4x4
var @uniform u_proj : float4x4
var @uniform u_cam_pos : float3
var @uniform u_time : float
var @uniform u_tex : sampler2D
var @inout w_pos : float3
var @inout w_normal : float3
var @inout w_uv : float2
var @out f_FragColor : float4
[vertex_program]
def vs_main {
let world = u_model * float4(a_pos, 1.0)
gl_Position = u_proj * u_view * world
w_pos = world.xyz
// model is rotation + uniform scale only, so a (normal, 0) multiply transforms
// the normal correctly without an inverse-transpose.
w_normal = (u_model * float4(a_normal, 0.0)).xyz
w_uv = a_uv
}
[fragment_program]
def fs_main {
let n = normalize(w_normal)
let v = normalize(u_cam_pos - w_pos)
let l = normalize(float3(0.5, 1.0, 0.3)) // warm key light from above-front
let key = max(dot(n, l), 0.0) * 0.7 + 0.3 // ambient floor 0.3
let rim = pow(1.0 - max(dot(n, v), 0.0), 2.0) // silhouette glow
let rim_color = float3(0.4, 0.6, 1.0) // cool cyan
let scrolled = float2(w_uv.x, w_uv.y + u_time * 0.05)
let albedo = texture(u_tex, scrolled).xyz
let lit = albedo * key + rim_color * rim
f_FragColor = float4(lit, 1.0)
}
var program : uint
var vao : uint
var vbo : uint
var ebo : uint
var texture : uint
var window : GLFWwindow?
var time : float = 0.0
[vertex_buffer]
struct Vertex {
xyz : float3
normal : float3
uv : float2
}
let vertices = [Vertex(
xyz=float3(1, 1, 1), normal=float3(0, 0, 1), uv=float2(0, 0)), Vertex(
xyz=float3(-1, 1, 1), normal=float3(0, 0, 1), uv=float2(1, 0)), Vertex(
xyz=float3(-1, -1, 1), normal=float3(0, 0, 1), uv=float2(1, 1)), Vertex(
xyz=float3(1, -1, 1), normal=float3(0, 0, 1), uv=float2(0, 1)), Vertex(
xyz=float3(1, 1, 1), normal=float3(1, 0, 0), uv=float2(0, 0)), Vertex(
xyz=float3(1, -1, 1), normal=float3(1, 0, 0), uv=float2(1, 0)), Vertex(
xyz=float3(1, -1, -1), normal=float3(1, 0, 0), uv=float2(1, 1)), Vertex(
xyz=float3(1, 1, -1), normal=float3(1, 0, 0), uv=float2(0, 1)), Vertex(
xyz=float3(1, 1, 1), normal=float3(0, 1, 0), uv=float2(0, 0)), Vertex(
xyz=float3(1, 1, -1), normal=float3(0, 1, 0), uv=float2(1, 0)), Vertex(
xyz=float3(-1, 1, -1), normal=float3(0, 1, 0), uv=float2(1, 1)), Vertex(
xyz=float3(-1, 1, 1), normal=float3(0, 1, 0), uv=float2(0, 1)), Vertex(
xyz=float3(-1, 1, 1), normal=float3(-1, 0, 0), uv=float2(0, 0)), Vertex(
xyz=float3(-1, 1, -1), normal=float3(-1, 0, 0), uv=float2(1, 0)), Vertex(
xyz=float3(-1, -1, -1), normal=float3(-1, 0, 0), uv=float2(1, 1)), Vertex(
xyz=float3(-1, -1, 1), normal=float3(-1, 0, 0), uv=float2(0, 1)), Vertex(
xyz=float3(-1, -1, -1), normal=float3(0, -1, 0), uv=float2(0, 0)), Vertex(
xyz=float3(1, -1, -1), normal=float3(0, -1, 0), uv=float2(1, 0)), Vertex(
xyz=float3(1, -1, 1), normal=float3(0, -1, 0), uv=float2(1, 1)), Vertex(
xyz=float3(-1, -1, 1), normal=float3(0, -1, 0), uv=float2(0, 1)), Vertex(
xyz=float3(1, -1, -1), normal=float3(0, 0, -1), uv=float2(0, 0)), Vertex(
xyz=float3(-1, -1, -1), normal=float3(0, 0, -1), uv=float2(1, 0)), Vertex(
xyz=float3(-1, 1, -1), normal=float3(0, 0, -1), uv=float2(1, 1)), Vertex(
xyz=float3(1, 1, -1), normal=float3(0, 0, -1), uv=float2(0, 1)
)];
let indices = fixed_array<int>(
0, 1, 2, 2, 3, 0,
4, 5, 6, 6, 7, 4,
8, 9, 10, 10, 11, 8,
12, 13, 14, 14, 15, 12,
16, 17, 18, 18, 19, 16,
20, 21, 22, 22, 23, 20)
let TEX_DIM = 256
// 256x256 RGBA8 synthwave horizon, generated CPU-side -- no asset file. Deep-
// purple-to-magenta sky with a banded neon sun, a magenta horizon line, and a
// dark-indigo ground with a cyan perspective grid receding to the vanishing point.
def gen_synthwave_texture(w, h : int) : array<uint8> {
var pixels : array<uint8>
pixels |> resize(w * h * 4)
let horizon = h / 2 - 8 // horizon a bit above center
let sun_cx = w / 2
let sun_cy = horizon - 26
let sun_r = 42
for (y in range(h)) {
for (x in range(w)) {
var r = 0
var g = 0
var b = 0
if (y < horizon) {
// sky: deep purple at top, magenta near horizon
let t = float(y) / float(horizon)
r = int(40.0 + t * 215.0)
g = int(6.0 + t * 64.0)
b = int(80.0 + t * 175.0)
// neon sun disk with horizontal scanline bands
let dx = x - sun_cx
let dy = y - sun_cy
if (dx * dx + dy * dy < sun_r * sun_r) {
let band = (y / 4) % 2
if (band == 0) {
let glow = clamp(1.0 - float(dy + sun_r) / float(2 * sun_r), 0.0, 1.0)
r = 255
g = int(120.0 + glow * 100.0)
b = int(60.0 + glow * 80.0)
}
}
} elif (y < horizon + 4) {
// bright magenta horizon line
r = 255
g = 90
b = 210
} else {
// ground: dark indigo with cyan perspective grid
r = 6
g = 6
b = 26
let dist = y - horizon - 4
let max_dist = h - horizon - 4
let scale = float(dist) / float(max_dist)
// horizontal lines, spacing widens with distance from horizon
let spacing = max(2, int(2.0 + scale * 18.0))
if (dist % spacing < 1) {
r = 0
g = 220
b = 255
}
// vertical lines converging to (w/2, horizon)
let dx = abs(x - w / 2)
let v_spacing = max(2, int(4.0 + scale * 40.0))
if (dx % v_spacing < 2 && scale > 0.02) {
r = (r + 0) / 2
g = (g + 200) / 2
b = (b + 255) / 2
}
}
let idx = (y * w + x) * 4
pixels[idx + 0] = uint8(clamp(r, 0, 255))
pixels[idx + 1] = uint8(clamp(g, 0, 255))
pixels[idx + 2] = uint8(clamp(b, 0, 255))
pixels[idx + 3] = 255u8
}
}
return <- pixels
}
def create_gl_objects {
program = create_shader_program(@@vs_main, @@fs_main)
glGenVertexArrays(1, safe_addr(vao))
glBindVertexArray(vao)
glGenBuffers(1, safe_addr(vbo))
glBindBuffer(GL_ARRAY_BUFFER, vbo)
glBufferData(GL_ARRAY_BUFFER, vertices, GL_STATIC_DRAW)
bind_vertex_buffer(null, type<Vertex>)
glGenBuffers(1, safe_addr(ebo))
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices, GL_STATIC_DRAW)
var pixels <- gen_synthwave_texture(TEX_DIM, TEX_DIM)
texture = load_image_from_bytes(TEX_DIM, TEX_DIM, unsafe(addr(pixels[0])))
delete pixels
}
[export]
def init {
if (glfwInit() == 0) {
panic("can't init glfw")
}
glfwInitOpenGL(3, 3)
window = glfwCreateWindow(640, 480, "OpenGL - 04 synthwave cube", null, null)
if (window == null) {
panic("can't create window")
}
glfwMakeContextCurrent(window)
create_gl_objects()
}
[export]
def update : bool {
time += 1.0 / 60.0
let t = time
var display_w, display_h : int
glfwGetFramebufferSize(window, display_w, display_h)
let h = max(display_h, 1)
let aspect = float(display_w) / float(h)
// camera orbits the cube; pass its world position to the fragment for the rim
let cam_pos = float3(cos(t * 0.5) * 4.0, 1.5, sin(t * 0.5) * 4.0)
// breathing scale + tumble about a tilted axis
let breathe = 1.0 + 0.05 * sin(t * 2.0)
let rot = quat_from_unit_vec_ang(normalize(float3(1.0, 1.0, 0.0)), t * 0.6)
u_model = compose(float3(0, 0, 0), rot, float3(breathe))
u_view = look_at_rh(cam_pos, float3(0, 0, 0), float3(0, 1, 0))
u_proj = perspective_rh_opengl(45.0 * PI / 180.0, aspect, 0.1, 50.0)
u_cam_pos = cam_pos
u_time = t
glViewport(0, 0, display_w, display_h)
glClearColor(0.02, 0.01, 0.05, 1.0)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glEnable(GL_DEPTH_TEST)
glDepthFunc(GL_LEQUAL)
glEnable(GL_CULL_FACE)
glCullFace(GL_BACK)
glUseProgram(program)
u_tex := texture
vs_main_bind_uniform(program)
fs_main_bind_uniform(program)
glBindVertexArray(vao)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, null)
glfwPollEvents()
glfwSwapBuffers(window)
return glfwWindowShouldClose(window) == 0
}
[export]
def shutdown {
glfwDestroyWindow(window)
glfwTerminate()
}
// Desktop driver. On the web this is never called -- the run path drives the
// three lifecycle functions directly and persists the Context across frames.
[export]
def main {
init()
while (update()) {
}
shutdown()
}
Geometry, matrices, depth
The cube is 24 vertices (four per face, so each face gets its own normal and a
full 0..1 UV square) and 36 indices. bind_vertex_buffer(null, type<Vertex>)
wires the interleaved (pos, normal, uv) layout to the three @in @location
attributes by declaration order. The vertex shader transforms the position by
proj * view * model and writes the world-space position and normal as
varyings; update() rebuilds the three matrices every frame with
look_at_rh / perspective_rh_opengl / a quaternion compose, uploaded as
float4x4 uniforms. glEnable(GL_DEPTH_TEST) plus a GL_DEPTH_BUFFER_BIT
clear keep the back faces hidden (the default clear-depth of 1.0 needs no explicit
glClearDepth – which is desktop-only anyway; GLES uses glClearDepthf).
The texture
gen_synthwave_texture builds a 256×256 RGBA8 image in an
array<uint8> – pure arithmetic per pixel, no asset – and
load_image_from_bytes uploads it via glTexImage2D. On the web this is the
first texture to cross into WebGL2: the CPU-generated pixels upload through the
same generated gl-call wrappers, and the mat4 uniforms through
glUniformMatrix4fv, with no special-casing.
The fragment lighting
fs_main normalizes the interpolated world normal, computes a warm key light
with an ambient floor and a cool-cyan rim term from the view direction, scrolls
the UV by u_time, samples the texture, and combines. The camera world
position (for the rim) and the time (for the scroll) are plain uniforms.
Run it
Locally, in a window:
daslang tutorials/opengl/04_cube/04_cube.das
In the browser, it runs live in the daslang playground – the same .das,
lowered to WebGL2, the textured cube spinning on your GPU.