AppleyAppley Docs
Docs/Optimization

GPU Instancing

Render thousands of identical objects in a single draw call using GPU instancing.

33 min read Advanced Updated 2026-07-01 v2.0 ~140 min to complete

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

This is part of the Advanced Documentation collection. Category: Optimization. Estimated completion: 140 minutes. Difficulty: Advanced/Professional.

Screenshot

GPU Instancing
GPU Instancing

How Professional Games Do It

1

U

n

2

N

o

3

I

d

4

O

b

5

B

u

6

C

o

7

U

s

8

A

d

9

H

a

10

G

r

11

U

p

12

F

o

FluxScript Implementation

Here is how to build this system in Appley using FluxScript (Appley Scripting):

GPU Instancing — FluxScript
// 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:

GPU Instancing — Node Graph
[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.