Procedural Content

Four modules of the acron package serve games whose worlds are made by code rather than shipped as assets: acron.noise makes deterministic fields, acron.gpu turns numpy arrays into textures, acron.geom builds meshes, and acron.quads keeps a pool of quads in one vertex buffer. They use numpy; import each module explicitly.

Deterministic noise

acron.noise is value noise built on exact 32-bit integer hashing, not on fract(sin(...)), so the same seed gives the same world on any machine. A terrain baked from these functions is sampled identically by the renderer and the physics.

Every function takes floats or numpy arrays; pass arrays to evaluate a whole field at once:

import numpy as np
from acron import noise

xs, zs = np.meshgrid(np.arange(512) / 64.0, np.arange(512) / 64.0)
height = noise.fbm(xs, zs, octaves=5, seed=7)        # 0..1
rims = noise.ridged(xs, zs, octaves=4, seed=7)       # sharp crests

hash2i(x, y, seed) is the hash itself, in [0, 1); vnoise is 2D value noise with a quintic fade; fbm and ridged sum octaves (lac and gain set the lacunarity and the falloff); pvn(x, y, per, seed) is periodic, for a tile that repeats without a seam. clamp, sstep, lerp and dmod are the small helpers the functions use.

RNG is a seeded mulberry32 stream: call it for a float in [0, 1), or uniform(lo, hi). Give each consumer (scatter, loot, effects) a stream of its own, so that one never shifts another’s rolls.

rng = noise.RNG(1234)
x = rng.uniform(-50.0, 50.0)

Arrays to textures

acron.gpu is the one place where numpy arrays become Texture objects, and it handles the two things that are easy to get wrong without noticing:

  • Row order. The engine’s RAM images start at the bottom row. A computed field indexed [z, x], with z growing the way texture v does, is uploaded as it is (float_texture, half_texture, byte_texture, rgb_texture, rgba_texture). A drawn image, painted top row first, is flipped on the way in: use image_texture for it.

  • Mipmaps. float_texture(..., mips=[base, level1, ...]) uploads the chain you computed, for a shader that samples specific pre-filtered levels.

from acron import gpu

height_tex = gpu.float_texture("height", height.astype(np.float32),
                               wrap=True)
terrain.set_shader_input("u_height", height_tex)

half_texture makes a half-float texture for a field that is re-uploaded often, and update_half_rows(tex, field, z0, z1) pushes only the rows that changed. vertex_view(vdata, floats_per_vertex) is a writable numpy view of a single-array GeomVertexData, one row per vertex, checked against the format’s stride.

For a scene that is built to be released whole (a chapter, a level), drop_buffer(base, buffer, camera) retires an offscreen buffer and the camera that drew into it in a safe order, and purge_state_caches() clears the engine’s render-state caches, which would otherwise keep the released scene’s textures alive, and runs the garbage collector.

Building meshes

GeomBuilder collects triangles from any number of primitives and bakes them into a single GeomNode with positions, normals and colours, so a model of a hundred parts is one draw call. The methods return the builder, so they chain:

import math
from acron.geom import GeomBuilder, color4, xform

hull = color4(0x8a9bb0)
lamp = color4(0xffd27a)

gb = GeomBuilder()
gb.rounded_box(2.0, 4.0, 1.0, 0.2, color=hull)
gb.cylinder(0.1, 0.1, 1.5, segs=8,
            xf=xform(pos=(0, 0, 1.2), rotate=((1, 0, 0), math.pi / 2)),
            color=hull)
gb.sphere(0.25, xf=xform(pos=(0, 0, 2.0)), color=lamp)
model = base.render.attach_new_node(gb.build("lamp_post"))

The conventions are fixed: boxes are centred on the origin; cylinders and cones run along +Y, centred; circles, rings and planes lie in the XY plane, facing +Z; lathe(profile) spins a list of (radius, height) points about +Y. The other shapes are box, cone, uv_sphere (also partial spheres), icosphere (with detail subdivisions and an optional displace function over all the vertices) and octahedron. xform(pos, rotate, scale) composes a transform for xf – scale, then the (axis, radians) rotation, then translation. color4(0xRRGGBB) converts an authored colour to linear. GeomBuilder(uv=True) also writes texture coordinates.

Quad pools

QuadPool keeps max_quads quads in one vertex buffer – particles, stars, billboards, stamps – with columns you name, written from numpy. Each column is (name, components), or (name, components, "texcoord") for the texture coordinates; a position column always comes first.

from acron.quads import QuadPool

pool = QuadPool("sparks", (("aVel", 3), ("aCorner", 2)), 256)
pool.fill_corners(6)       # aCorner: after position (3) and aVel (3)
np_ = pool.attach(base.render)
np_.set_shader(spark_shader)

q = pool.quads()           # shape (256, 4, stride)
q[:, :, 0:3] = positions[:, None, :]
q[:, :, 3:6] = velocities[:, None, :]

fill_corners(offset) writes the per-corner column, -1..1 pairs, that a vertex shader offsets each corner by (unit=True writes 0..1, which doubles as UVs): the engine’s OpenGL context ignores gl_PointSize, so camera-facing quads are the way to draw point sprites. view() is the buffer one row per vertex, quads() the same buffer per quad; taking either marks the rows changed, so take it once in a frame you write. attach(parent) makes the node never culled, for geometry written in world space.

resize(max_quads) changes the size in place: the node and its vertex data stay the same objects, so the shader inputs, bin and hide flags set on the node survive.