Skip to main content

Hello Cube

This tutorial demonstrates how to render a spinning textured cube using luma.gl's cross-platform rendering APIs.

caution

Tutorials are maintained on a best-effort basis and may not be fully up to date (contributions welcome). Working versions of these examples are found in the /examples/tutorials directory of the luma.gl repository.

Hello Cube

Drawing a textured cube

Rendered using the luma.gl Model, CubeGeometry and AnimationLoop classes.

It is assumed you've set up your development environment as described in Setup.

The cube shaders sample from a 2D texture and apply a model-view-projection matrix. A UniformStore tracks that matrix so it can be updated every frame as the cube rotates. The texture is loaded asynchronously and bound through the model's bindings property along with the uniform buffer.

The complete source for this example is shown below. It builds a Model with WGSL and GLSL shaders, manages uniforms with a UniformStore, and draws a CubeGeometry inside an AnimationLoopTemplate.

import type {NumberArray, VariableShaderType} from '@luma.gl/core';
import {Texture, UniformStore} from '@luma.gl/core';
import {
AnimationLoopTemplate,
makeAnimationLoop,
type AnimationProps,
Model,
CubeGeometry,
loadImageBitmap,
AsyncTexture
} from '@luma.gl/engine';
import {webgl2Adapter} from '@luma.gl/webgl';
import {webgpuAdapter} from '@luma.gl/webgpu';

import {Matrix4} from '@math.gl/core';

const WGSL_SHADER = /* WGSL */ `
struct Uniforms {
modelViewProjectionMatrix : mat4x4<f32>,
};

@group(0) @binding(0) var<uniform> app : Uniforms;
@group(0) @binding(1) var uTexture : texture_2d<f32>;
@group(0) @binding(2) var uTextureSampler : sampler;

struct VertexInputs {
@location(0) positions : vec4<f32>,
@location(1) texCoords : vec2<f32>
};

struct FragmentInputs {
@builtin(position) Position : vec4<f32>,
@location(0) fragUV : vec2<f32>,
@location(1) fragPosition: vec4<f32>,
}

@vertex
fn vertexMain(inputs: VertexInputs) -> FragmentInputs {
var outputs : FragmentInputs;
outputs.Position = app.modelViewProjectionMatrix * inputs.positions;
outputs.fragUV = inputs.texCoords;
outputs.fragPosition = 0.5 * (inputs.positions + vec4(1.0, 1.0, 1.0, 1.0));
return outputs;
}

@fragment
fn fragmentMain(inputs: FragmentInputs) -> @location(0) vec4<f32> {
return textureSample(uTexture, uTextureSampler, inputs.fragUV);
}
`;

// GLSL
const VS_GLSL = /* glsl */ `
#version 300 es
#define SHADER_NAME cube-vs

uniform appUniforms {
mat4 modelViewProjectionMatrix;
} app;

layout(location=0) in vec3 positions;
layout(location=1) in vec2 texCoords;

out vec2 fragUV;
out vec4 fragPosition;

void main() {
gl_Position = app.modelViewProjectionMatrix * vec4(positions, 1.0);
fragUV = texCoords;
fragPosition = 0.5 * (vec4(positions, 1.) + vec4(1., 1., 1., 1.));
}
`;

const FS_GLSL = /* glsl */ `
#version 300 es
#define SHADER_NAME cube-fs
precision highp float;

uniform sampler2D uTexture;

in vec2 fragUV;
in vec4 fragPosition;

layout (location=0) out vec4 fragColor;

void main() {
fragColor = texture(uTexture, vec2(fragUV.x, 1.0 - fragUV.y));
}
`;

type AppUniforms = {
mvpMatrix: NumberArray;
};

const app: {uniformTypes: Record<keyof AppUniforms, VariableShaderType>} = {
uniformTypes: {
mvpMatrix: 'mat4x4<f32>'
}
};

const eyePosition = [0, 0, -4];

class AppAnimationLoopTemplate extends AnimationLoopTemplate {
mvpMatrix = new Matrix4();
viewMatrix = new Matrix4().lookAt({eye: eyePosition});
model: Model;
uniformStore = new UniformStore<{app: AppUniforms}>({app});

constructor({device}: AnimationProps) {
super();

const texture = new AsyncTexture(device, {
usage: Texture.TEXTURE | Texture.RENDER_ATTACHMENT | Texture.COPY_DST,
data: loadImageBitmap('vis-logo.png'),
flipY: true,
mipmaps: true,
sampler: device.createSampler({
minFilter: 'linear',
magFilter: 'linear',
mipmapFilter: 'linear'
})
});

this.model = new Model(device, {
id: 'rotating-cube',
source: WGSL_SHADER,
vs: VS_GLSL,
fs: FS_GLSL,
geometry: new CubeGeometry({indices: false}),
bindings: {
app: this.uniformStore.getManagedUniformBuffer(device, 'app'),
uTexture: texture
},
parameters: {
depthWriteEnabled: true,
depthCompare: 'less-equal'
}
});
}

onFinalize() {
this.model.destroy();
this.uniformStore.destroy();
}

onRender({device, aspect, tick}: AnimationProps) {
this.mvpMatrix
.perspective({fovy: Math.PI / 3, aspect})
.multiplyRight(this.viewMatrix)
.rotateX(tick * 0.01)
.rotateY(tick * 0.013);

this.uniformStore.setUniforms({
app: {mvpMatrix: this.mvpMatrix}
});

const renderPass = device.beginRenderPass({clearColor: [0, 0, 0, 1], clearDepth: 1});
this.model.draw(renderPass);
renderPass.end();
}
}

const animationLoop = makeAnimationLoop(AnimationLoopTemplate, {adapters: [webgpuAdapter, webgl2Adapter]})
animationLoop.start();

During rendering the animation loop recalculates the matrix, updates the uniform buffer and draws the cube inside a render pass. The fragment shader samples the bound texture to shade each face.

Running the application will display a rotating cube textured with the vis.gl logo.