Rendering and Shaders #
Budo combines Skia drawing with a WebGL 2 and OpenGL ES 3 style shader pipeline. You can draw normal 2D canvas content, then run fullscreen or regional shader passes that sample the canvas as u_canvas. You can also draw meshes, upload buffers, bind textures, create Skia-backed canvas textures, and use offscreen render targets.
The goal is not to expose every native graphics API. The goal is to provide a portable rendering surface that behaves consistently on desktop, Android, and web.
The frame pipeline #
Within a frame, app code issues Skia drawing commands immediately and sys.gl draw calls submit immediately. Present only flushes or composites the default canvas when needed and swaps the platform window. Fullscreen and region passes can sample the current canvas texture through the built-in u_canvas uniform.
The canvas path is GPU-immediate. Budo does not render by recording a CPU display list and uploading it at the end of the frame. This matters when you mix drawing, flushing, shader passes, and pixel reads.
// Draw one frame that mixes Skia canvas drawing and a shader pass.
function frame(t) {
// Clear the canvas before drawing text into it.
sys.canvas.clear('#05060a');
// Select a white fill for text.
sys.canvas.setFillColor('#ffffff');
// Draw text that the shader will sample through u_canvas.
sys.canvas.drawText('Shader input', 32, 64, 28);
// Submit pending canvas work before sampling it in GL.
sys.canvas.flush();
// Bind the default screen framebuffer.
sys.gl.bindScreen();
// Run the shader program as a fullscreen pass.
program.use().drawFullscreen();
// Request the next frame.
sys.animation.requestFrame(frame);
}The shader pass runs when drawFullscreen is called. Flush the canvas first when a shader needs to sample Skia content that was drawn earlier in the same frame.
Shader programs #
Create programs from project-relative vertex and fragment shader files.
// Create a shader program from project-relative files.
const program = sys.gl.createShaderProgram({
// Vertex shader path.
vertex: 'shader.vert',
// Fragment shader path.
fragment: 'shader.frag'
});Author new shaders as GLSL ES 300. The same sources run on Android GLES 3 and WebGL 2. Desktop translates the shader source for its OpenGL core profile.
// Use GLSL ES 3.00 for WebGL 2 and OpenGL ES 3 portability.
#version 300 es
// Pick a default floating-point precision for fragment-compatible varyings.
precision mediump float;
// Receive clip-space quad positions from the built-in fullscreen layout.
in vec2 a_position;
// Receive texture coordinates for sampling the canvas.
in vec2 a_texCoord;
// Pass texture coordinates to the fragment shader.
out vec2 v_uv;
// Run once for every vertex.
void main() {
// Forward the texture coordinate unchanged.
v_uv = a_texCoord;
// Emit a clip-space position for the fullscreen quad.
gl_Position = vec4(a_position, 0.0, 1.0);
}// Use GLSL ES 3.00 for WebGL 2 and OpenGL ES 3 portability.
#version 300 es
// Choose a balanced default precision.
precision mediump float;
// Sample the current Budo canvas texture.
uniform sampler2D u_canvas;
// Read the active viewport size when needed.
uniform vec2 u_resolution;
// Read runtime time in seconds.
uniform float u_time;
// Receive interpolated texture coordinates from the vertex shader.
in vec2 v_uv;
// Write the final fragment color.
out vec4 fragColor;
// Run once for each pixel covered by the fullscreen pass.
void main() {
// Sample the canvas at this pixel's UV coordinate.
vec4 base = texture(u_canvas, v_uv);
// Build a moving horizontal wave from UV and time.
float wave = 0.5 + 0.5 * sin((v_uv.x + u_time) * 20.0);
// Modulate the canvas color while preserving alpha.
fragColor = vec4(base.rgb * (0.75 + wave * 0.25), base.a);
}Built-in uniforms are provided when present: u_canvas, u_resolution, u_time, and for region draws u_offset.
Uniforms and immediate state #
sys.gl follows the familiar immediate GL model. Uniform setters update the target program now, texture binding calls bind sampler state now, updateBuffer calls upload with glBufferSubData now, and draw calls submit work now.
// Draw once with a subtle strength.
program.uniform1f('u_strength', 0.2).drawFullscreen();
// Change the uniform and draw again immediately.
program.uniform1f('u_strength', 0.8).drawFullscreen();Those two draws use different values because the uniform changes before each draw. This behavior is especially important when drawing the same program multiple times with different transforms, colors, textures, or render targets.
Bind the screen or an explicit render target before drawing. The object-style API wraps the handle API, so both styles execute immediately.
Program object API #
sys.gl.createProgram returns a numeric handle. sys.gl.createShaderProgram returns a ShaderProgram object that wraps a handle and exposes chainable helpers for the most common immediate operations.
// Create the object-style program wrapper.
const shader = sys.gl.createShaderProgram('shader.vert', 'shader.frag');
// Set state and draw immediately through the wrapper.
shader
.use()
.uniform1f('u_strength', 0.7)
.uniform2f('u_mouse', input.pointer.x, input.pointer.y)
.drawFullscreen();The object methods are use, uniform1i, uniform1f, uniform2f, uniform3f, uniform4f, uniformMatrix4, texture, canvasTexture, renderTargetTexture, drawFullscreen, drawRegion, and drawMesh. Use the object form when a draw reads like a small sentence. Use the handle form when you are storing IDs in data tables, passing them through generic render code, or using lower-level calls such as array uniforms and instanced drawing.
The Immediate variants accept either a numeric program handle or a ShaderProgram object. They are useful in generic render helpers that do not care which style the caller used.
// Draw with a handle or an object through the same helper.
function drawPass(shaderOrHandle, target) {
// Submit a fullscreen pass immediately.
sys.gl.drawFullscreenImmediate(shaderOrHandle);
// Submit a region pass immediately into an optional render target.
sys.gl.drawRegionImmediate(shaderOrHandle, 0, 0, 320, 180, target);
}For mesh helpers, sys.gl.drawMeshImmediate(shaderOrHandle, layout, options) mirrors sys.gl.drawMesh(handle, layout, options) while accepting either program representation.
Render targets #
Render targets are offscreen textures that shader passes can draw into and later sample.
// Create an offscreen target for the first pass.
const scene = sys.gl.createRenderTarget(512, 512);
// Create an offscreen target for the blur pass.
const blur = sys.gl.createRenderTarget(512, 512);
// Draw the scene shader into the scene target.
sys.gl.drawRegion(sceneProgram, 0, 0, 512, 512, scene);
// Bind the scene texture so the blur shader can sample it.
sys.gl.bindTexture(blurProgram, 'u_input', scene, 1);
// Draw the blur shader into the blur target.
sys.gl.drawRegion(blurProgram, 0, 0, 512, 512, blur);
// Bind the blurred target for the final composite.
sys.gl.bindTexture(finalProgram, 'u_blur', blur, 1);
// Draw the final composite to the screen.
sys.gl.drawFullscreen(finalProgram);Pass true as the third argument to createRenderTarget when a target needs a depth buffer for 3D rendering.
// Request a render target with a depth buffer for 3D drawing.
const target = sys.gl.createRenderTarget(width, height, true);Resize targets when the viewport changes, and destroy them when a long-running tool no longer needs them.
You can also bind targets explicitly and draw with program objects:
// Bind an offscreen target before drawing 3D content into it.
sys.gl.bindRenderTarget(scene);
// Draw indexed or array mesh data with depth testing enabled.
sceneProgram.drawMesh(layout, { mode: 'triangles', count: indexCount, depthTest: true });
// Return to the default screen framebuffer.
sys.gl.bindScreen();
// Sample the scene target in the final fullscreen pass.
finalProgram.renderTargetTexture('u_scene', scene, 1).drawFullscreen();Canvas textures #
sys.graphics.createCanvasTexture(width, height) creates an offscreen Skia canvas backed by a GL texture and framebuffer. Draw into its canvas, flush it, then bind it to a shader sampler.
// Create a 512 by 256 Skia surface backed by a GL texture.
const ui = sys.graphics.createCanvasTexture(512, 256);
// Create a shader program that will sample that texture.
const post = sys.gl.createShaderProgram('shader.vert', 'post.frag');
// Clear the offscreen canvas to transparent.
ui.canvas.clear('transparent');
// Draw text into the offscreen canvas.
ui.canvas.drawText('Budo', 32, 96, 42);
// Flush drawing so the backing GL texture is current.
ui.flush();
// Bind the screen before the final pass.
sys.gl.bindScreen();
// Bind the canvas texture to u_ui and draw the fullscreen pass.
post.canvasTexture('u_ui', ui, 1).drawFullscreen();The high-level canvasTexture helper flushes before binding. Calling flush() yourself keeps synchronization explicit and works the same way across desktop, Android, and web.
The nested surface.canvas object is intentionally smaller than the main canvas. It supports clear, flush, drawRect, drawCircle, and drawText. Use it for labels, badges, UI panels, dynamic glyph atlases, and other 2D content that should become a texture. For transforms, paths, SVGs, filters, and full paint control, draw to the main canvas or build a larger texture workflow explicitly.
Canvas texture handles expose id, width, height, texture, target, canvas, flush, resize, and destroy. The texture field is the backing GL texture ID, and target is the backing framebuffer ID.
Region draws #
drawRegion runs a shader within a rectangular viewport. The region can draw to the screen or into a render target.
// Send the current pointer position to the shader.
sys.gl.setUniform2f(program, 'u_mouse', input.pointer.x, input.pointer.y);
// Draw the shader only inside the given rectangle.
sys.gl.drawRegion(program, 32, 32, 320, 180);Inside a region shader, u_resolution is the region size rather than the full window. u_offset is the top-left position of the region in canvas coordinates.
Mesh drawing #
Budo's GL surface includes a compact 3D pipeline: vertex buffers, index buffers, vertex layouts, textures, matrix uniforms, and instanced drawing.
// Create a vertex buffer with three 3D positions.
const vbo = sys.gl.createBuffer('vertex', new Float32Array([
// Bottom-left vertex.
-0.5, -0.5, 0.0,
// Bottom-right vertex.
0.5, -0.5, 0.0,
// Top vertex.
0.0, 0.5, 0.0
]));
// Create a vertex layout that describes how buffers feed attributes.
const layout = sys.gl.createVertexLayout();
// Bind attribute location 0 to three float values per vertex.
sys.gl.setAttribute(layout, 0, vbo, 3, 'float', false, 12, 0);
// Draw the three vertices as one triangle.
sys.gl.drawMesh(program, layout, {
// Use triangle primitives.
mode: 'triangles',
// Start at the first vertex.
first: 0,
// Draw three vertices.
count: 3
});Standard attribute locations used by the built-in mesh helper examples are position at 0, UV at 1, normal at 2, color at 3, and tangent at 4.
For higher-level geometry, use the pure JavaScript helper module in types/mesh.js.
// Import the helper that builds and uploads common meshes.
import { Mesh } from './../../types/mesh.js';
// Build cube geometry in JavaScript.
const cube = Mesh.cube(1.0);
// Upload the cube buffers and layout to the runtime.
const uploaded = Mesh.upload(cube);
// Draw the uploaded cube geometry.
sys.gl.drawMesh(program, uploaded.layout, {
// Use triangle primitives.
mode: 'triangles',
// Draw every uploaded index.
count: uploaded.indexCount,
// Match the index type chosen by the helper.
indexType: uploaded.indexType
});Textures #
The texture API can load images from project files or create raw 2D textures.
// Decode a project-relative image and upload it as a texture.
const albedo = sys.gl.loadTexture2D('assets/floor.png');
const fetchedAlbedo = sys.gl.loadTexture2DFromBuffer(await (await fetch(url)).arrayBuffer());
// Bind the texture to the shader sampler u_albedo on unit 1.
sys.gl.bindTexture2D(program, 'u_albedo', albedo, 1);Unit 0 is reserved for the canvas sampler used by fullscreen and region passes. Use units 1 through 7 for your own textures and render target bindings.
Cubemaps are loaded from six image paths, or from six image buffers with loadTextureCubeFromBuffer:
// Load six project-relative images into a cubemap.
const sky = sys.gl.loadTextureCube([
// X axis faces, positive then negative.
'sky/+x.png', 'sky/-x.png',
// Y axis faces, positive then negative.
'sky/+y.png', 'sky/-y.png',
// Z axis faces, positive then negative.
'sky/+z.png', 'sky/-z.png'
]);
const skyFromBytes = sys.gl.loadTextureCubeFromBuffer([px, nx, py, ny, pz, nz]);Buffer updates #
updateBuffer uploads immediately. This lets you update one dynamic buffer several times in the same frame and have each following draw receive the right data.
// Fill the reusable vertex array for the first shape.
buildVertices(vertices, firstShape);
// Upload the new vertex data immediately.
sys.gl.updateBuffer(vbo, vertices, 0);
// Draw using the first shape's data.
sys.gl.drawMesh(program, layout, firstOptions);
// Refill the same array for the second shape.
buildVertices(vertices, secondShape);
// Upload the replacement data before the second draw.
sys.gl.updateBuffer(vbo, vertices, 0);
// Draw using the second shape's data.
sys.gl.drawMesh(program, layout, secondOptions);Full buffer creation and reupload operations are immediate too, and usually happen during initialization or asset rebuilds rather than per draw.
Error handling #
Shader creation and invalid JavaScript calls raise exceptions. sys.gl.getLastError() returns the last OpenGL or shader-related error string and clears it when appropriate.
// Catch shader creation failures so the app can report them cleanly.
try {
// Attempt to compile and link a shader program from two files.
const program = sys.gl.createProgram('missing.vert', 'missing.frag');
} catch (error) {
// Print the compiler or file-loading error.
console.log('Could not create shader: ' + error);
}For shader-heavy tools, surface creation errors in your own UI. A missing uniform is often a typo; a failed program compile is usually easier to diagnose if you print the compiler log early.
Practical performance notes #
Create programs, render targets, vertex layouts, and textures during setup rather than inside every frame. Reuse typed arrays for dynamic geometry. Avoid readPixels as a per-frame effect. Prefer a few clear passes over elaborate state churn.
Most importantly, keep your render graph explicit in code. Budo's shader pipeline is small enough that a well-named sequence of draw calls is often clearer than building a general abstraction too early.