GPU Instancing
Render thousands of identical objects in a single draw call using GPU instancing.
Introduction
Forest of 10,000 trees, battlefield of 1,000 soldiers, field of 5,000 grass blades — GPU instancing renders them all in one draw call. This guide explains how professional engines batch identical meshes and use per-instance attributes — then builds instancing in Appley.
Advanced Guide 19 of 25
Screenshot
How Professional Games Do It
U
n
N
o
I
d
O
b
B
u
C
o
U
s
A
d
H
a
G
r
U
p
F
o
FluxScript Implementation
Here is how to build this system in Appley using FluxScript (Appley Scripting):
// GPU instancing system for forests, grass, and crowds
const MAX_INSTANCES = 10000
on start {
// Register instancable object types
instanceGroups = {
tree_pine: { mesh: "tree_pine", material: "tree_bark", instances: [] },
grass_blade: { mesh: "grass_blade", material: "grass", instances: [] },
rock: { mesh: "rock_01", material: "rock", instances: [] }
}
// Populate forest with 5000 trees
populateForest()
// Populate grass with 10000 blades
populateGrass()
// Build instance buffers
for groupName in instanceGroups {
buildInstanceBuffer(groupName)
}
}
func populateForest() {
group = instanceGroups.tree_pine
for i = 0 to 5000 {
// Random position within forest area
x = math.random(-200, 200)
z = math.random(-200, 200)
y = terrain.getHeight(x, z)
// Per-instance variation
scale = 0.7 + math.random(0.6)
rotationY = math.random(360)
colorTint = math.random(0.2)
windPhase = math.random(6.28)
group.instances.push({
position: vec3(x, y, z),
rotation: vec3(0, rotationY, 0),
scale: vec3(scale, scale, scale),
tint: colorTint,
windPhase: windPhase
})
}
}
func buildInstanceBuffer(groupName) {
group = instanceGroups[groupName]
if group.instances.length == 0 return
// Create instance data buffer
// Each instance: position(3) + rotation(3) + scale(3) + tint(1) + wind(1) = 11 floats
data = new Float32Array(group.instances.length * 11)
for i = 0 to group.instances.length {
inst = group.instances[i]
offset = i * 11
data[offset] = inst.position.x
data[offset + 1] = inst.position.y
data[offset + 2] = inst.position.z
data[offset + 3] = inst.rotation.x
data[offset + 4] = inst.rotation.y
data[offset + 5] = inst.rotation.z
data[offset + 6] = inst.scale.x
data[offset + 7] = inst.scale.y
data[offset + 8] = inst.scale.z
data[offset + 9] = inst.tint
data[offset + 10] = inst.windPhase
}
// Create instanced render object
instancedMesh = meshBuilder.createInstanced({
baseMesh: group.mesh,
material: group.material,
instanceData: data,
instanceCount: group.instances.length,
instanceStride: 11 * 4 // 11 floats * 4 bytes
})
group.renderObject = instancedMesh
scene.add(instancedMesh)
}
// Dynamic instance updates (e.g., wind animation)
on update(dt) {
// Update wind for all grass instances
grassGroup = instanceGroups.grass_blade
if grassGroup.instances.length > 0 {
// Update wind phase in shader (no buffer update needed)
grassGroup.renderObject.material.setUniform("windTime", time.now())
grassGroup.renderObject.material.setUniform("windStrength", world.getWind())
}
}
// Add/remove instances dynamically (e.g., tree chopping)
func removeInstance(groupName, index) {
group = instanceGroups[groupName]
group.instances[index] = group.instances[group.instances.length - 1]
group.instances.pop()
buildInstanceBuffer(groupName) // Rebuild buffer
}
// Culling for instance groups — only render visible instances
on update(dt) {
camera = scene.find("MainCamera")
for groupName in instanceGroups {
group = instanceGroups[groupName]
if group.renderObject == null continue
// Frustum cull the entire group
groupBounds = calculateGroupBounds(group.instances)
if !camera.frustum.intersects(groupBounds) {
group.renderObject.setVisible(false)
continue
}
// Per-instance culling for large groups
if group.instances.length > 1000 {
visibleIndices = []
for i = 0 to group.instances.length {
inst = group.instances[i]
instBounds = bounds.sphere(inst.position, 2)
if camera.frustum.intersects(instBounds) {
visibleIndices.push(i)
}
}
// Update instance buffer with only visible instances
group.renderObject.setActiveInstances(visibleIndices)
}
group.renderObject.setVisible(true)
}
}Node Graph Equivalent
The same system implemented as a Node Graph in Appley Node Scripting:
[On Start] --> [Register: instance groups (tree, grass, rock)]
--> [populateForest: 5000 trees with random pos/scale/rotation]
--> [populateGrass: 10000 blades with wind phase]
--> [For Each Group: buildInstanceBuffer]
--> [Create: Float32Array with 11 floats per instance]
--> [Create: instanced mesh with instance data buffer]
[On Update]
--> [Set: wind time + strength uniform (no buffer update)]
--> [For Each Instance Group]
--> [Frustum cull: entire group bounds]
+-- Outside --> [Hide entire group]
--> [Branch: group > 1000 instances]
+-- True --> [Per-instance frustum cull]
--> [setActiveInstances: only visible indices]
[Function: removeInstance]
--> [Swap: with last instance] --> [Pop: from array]
--> [Rebuild: instance buffer]Professional Optimization Tips
How AAA studios optimize these systems — applicable to your Appley project:
- Use per-instance color variation. 5000 identical trees look like copy-paste. Add a per-instance tint attribute (0.8-1.2 brightness). The shader multiplies the base color by this tint. Trees look unique despite one draw call.
- Frustum cull per-instance for large groups. 10,000 grass blades include many off-screen. Build a dynamic visible instance list each frame. Only visible instances are rendered, saving massive GPU time.
- Use material atlases for variety. Trees with different leaf colors cannot be batched. Put all leaf textures in an atlas. Per-instance attribute selects the atlas region. Now all trees batch in one call.
- Use shader-based wind, not CPU updates. Wind animation should be in the vertex shader using a per-instance wind phase and a global wind time uniform. CPU updates of 10,000 instances per frame is too expensive.
Common Mistakes
Rebuilding the instance buffer every frame for static objects
Static forests do not need buffer updates. Build once at start. Only rebuild when instances are added/removed (tree chopped). Per-frame rebuilds waste CPU.
No per-instance variation
Without per-instance tint/scale/rotation, all instances look identical. Forests look artificial. Add random variation per instance to create natural appearance.
