Atlas and Batch Rendering
When multiple sprites share the same texture (an atlas / sprite sheet), or when you need to draw thousands of sprites, you can use UV sub-regions, Sprite resources, and SpriteBatch batch rendering to organize resources and improve performance.
UV Sub-Region (Atlas)
An atlas (atlas / spritesheet) arranges multiple small images on a single texture. By specifying the sub-region (x, y, w, h) to sample through SpriteRenderer.uvRect (all normalized coordinates from 0 to 1), you can display only a single cell from the atlas:
import { Vector4 } from '@orillusion/core';
// Sample the top-left 1/4 region of the atlas
sprite.texture = atlasTexture;
sprite.uvRect = new Vector4(0, 0, 0.5, 0.5);Sprite Resource
Sprite is a reusable sprite resource object that packages "texture + sub-region + pivot" together, making it convenient to share among multiple SpriteRenderers:
import { Sprite, Vector4, Vector2 } from '@orillusion/core';
// Approach 1: quickly create from a texture
const sprite1 = Sprite.fromTexture(atlasTexture, 'icon');
// Approach 2: specify the atlas sub-region and pivot during construction
const sprite2 = new Sprite({
texture: atlasTexture,
region: new Vector4(0.5, 0, 0.5, 0.5), // The sub-region in the atlas
pivot: new Vector2(0.5, 0.5),
name: 'coin',
});
// Assign it to the renderer
spriteRenderer.sprite = sprite2;| Member | Type | Description |
|---|---|---|
texture | Texture | The texture |
region | Vector4 | The atlas sub-region (x, y, w, h), in normalized coordinates |
pivot | Vector2 | The pivot (0~1) |
Sprite.fromTexture(tex, name?) | Static | Quickly creates a Sprite from an entire texture |
Example
import {AtmosphericComponent, BillboardComponent, BillboardType, BitmapTexture2D, CameraUtil, DirectLight, Engine3D, HoverCameraController, Object3D, Scene3D, SpriteRenderer, Vector2, View3D, Sprite, Vector4, Color } from "@orillusion/core";
import * as dat from 'dat.gui';
class Sample_atlas {
private sprite!: SpriteRenderer;
private spriteObj!: Object3D;
private billboard!: BillboardComponent;
private gui!: dat.GUI;
private readonly s = {
width: 2,
height: 2,
x: 0,
y: 2,
z: 0,
cornerRadius: 0,
billboard: BillboardType.None,
};
async run() {
const engine = await Engine3D.init({});
const scene = new Scene3D();
const sky = scene.addComponent(AtmosphericComponent);
this.gui = new dat.GUI();
const camera = CameraUtil.createCamera3DObject(scene);
camera.perspective(60, engine.aspect, 0.1, 5000);
camera.object3D.addComponent(HoverCameraController).setCamera(0, -15, 2);
const view = new View3D();
view.scene = scene;
view.camera = camera;
engine.startRenderView(view);
// Sun light for the atmospheric sky
const lightObj = new Object3D();
lightObj.rotationX = 45; lightObj.rotationY = 110;
lightObj.addComponent(DirectLight).intensity = 3;
scene.addChild(lightObj);
sky.relativeTransform = lightObj.transform;
// Load a texture
const atlasTexture = new BitmapTexture2D();
await atlasTexture.load('https://cdn.orillusion.com/particle/crystal_debug.png');
// The sprite
this.spriteObj = new Object3D();
this.sprite = this.spriteObj.addComponent(SpriteRenderer);
this.sprite.texture = atlasTexture;
this.spriteObj.localPosition.set(this.s.x, this.s.y, this.s.z);
scene.addChild(this.spriteObj);
this.sprite.size = new Vector2(this.s.width, this.s.height);
this.sprite.cornerRadius = this.s.cornerRadius;
// Billboard is composed as a separate component
this.billboard = this.spriteObj.addComponent(BillboardComponent);
this.billboard.type = this.s.billboard;
{
const sprite1 = new Sprite({
texture: atlasTexture,
region: new Vector4(0, 0, 1.0 / 4, 1.0 / 4),
pivot: new Vector2(0.5, 0.5),
name: '1',
});
const Obj = new Object3D();
const spriteObj = Obj.addComponent(SpriteRenderer);
spriteObj.color = new Color(1, 1, 0, 1);
spriteObj.sprite = sprite1;
scene.addChild(Obj);
}
this.initGUI();
}
private initGUI() {
const s = this.s;
const folder = this.gui.addFolder('Basic');
folder.add(s, 'width', 0.2, 10, 0.1).onChange(v => this.sprite.size = new Vector2(v, s.height));
folder.add(s, 'height', 0.2, 10, 0.1).onChange(v => this.sprite.size = new Vector2(s.width, v));
folder.add(s, 'x', -10, 10, 0.1).onChange(v => this.spriteObj.x = v);
folder.add(s, 'y', -10, 10, 0.1).onChange(v => this.spriteObj.y = v);
folder.add(s, 'z', -10, 10, 0.1).onChange(v => this.spriteObj.z = v);
folder.add(s, 'cornerRadius', 0, 1, 0.01).onChange(v => this.sprite.cornerRadius = v);
folder.add(s, 'billboard', {
None: BillboardType.None,
'Billboard Y': BillboardType.BillboardY,
'Billboard XYZ': BillboardType.BillboardXYZ,
}).onChange(v => this.billboard.type = Number(v));
folder.open();
}
}
new Sample_atlas().run();Batch Rendering (SpriteBatch)
When you need to draw a large number of sprites that share the same texture (such as particle patches, massive numbers of icons, or bullet-hell projectiles), using a SpriteRenderer for each one would produce a large number of draw calls. SpriteBatch merges them into a single draw call, significantly improving performance.
import { Object3D, SpriteBatch, Color, Vector2, Vector3, Vector4 } from '@orillusion/core';
const batchObj = new Object3D();
const batch = batchObj.addComponent(SpriteBatch);
batch.texture = tex; // The whole batch shares a single texture
batch.color = new Color(1, 1, 1, 1); // The overlay color for the whole batch
scene.addChild(batchObj);
// Add entries one by one; add() returns a handle that can be updated later
const entry = batch.add({
position: new Vector3(x, y, z),
size: new Vector2(1, 1),
pivot: new Vector2(0.5, 0.5),
uvRect: new Vector4(0, 0, 1, 1), // The atlas sub-region
});Common SpriteBatch methods:
| Method | Description |
|---|---|
add(spec) | Adds a sprite entry and returns a SpriteBatchEntry handle |
update(entry, patch) | Updates an entry's position / size / pivot / uvRect, etc. |
remove(entry) | Removes an entry |
clear() | Clears all entries |
entries | Read-only, all current entries |
Update entries at runtime (for example, making each sprite float):
batch.update(entry, { position: new Vector3(x, newY, z) });Example
import {AtmosphericComponent, BitmapTexture2D, CameraUtil, Color, DirectLight, Engine3D, HoverCameraController, Object3D, Scene3D, SpriteBatch, SpriteBatchEntry, Vector2, Vector3, Vector4, View3D } from "@orillusion/core";
import * as dat from 'dat.gui';
class Sample_Batch {
engine!: Engine3D;
scene!: Scene3D;
view!: View3D;
gui!: dat.GUI;
private batch!: SpriteBatch;
private entries: SpriteBatchEntry[] = [];
private animate = true;
private phases: Float32Array = new Float32Array(0);
private basePositions: Float32Array = new Float32Array(0);
private readonly state = {
count: 10_000,
tint: new Color(1, 1, 1, 1),
animate: true,
size: 0.15,
spread: 30,
};
async run() {
this.engine = await Engine3D.init({
renderLoop: () => this._tick(),
});
this.gui = new dat.GUI();
this.scene = new Scene3D();
const sky = this.scene.addComponent(AtmosphericComponent);
const camera = CameraUtil.createCamera3DObject(this.scene);
camera.perspective(60, this.engine.aspect, 0.1, 5000);
camera.object3D.addComponent(HoverCameraController).setCamera(0, -8, 30);
this.view = new View3D();
this.view.scene = this.scene;
this.view.camera = camera;
this.engine.startRenderView(this.view);
const lightObj = new Object3D();
lightObj.rotationX = 45;
lightObj.rotationY = 110;
lightObj.addComponent(DirectLight).intensity = 3;
this.scene.addChild(lightObj);
sky.relativeTransform = lightObj.transform;
const tex = new BitmapTexture2D();
tex.flipY = true;
await tex.load('https://cdn.orillusion.com/textures/KB3D_NTT_Ads_basecolor.png');
const batchObj = new Object3D();
this.batch = batchObj.addComponent(SpriteBatch);
this.batch.texture = tex;
this.batch.color = this.state.tint;
this.scene.addChild(batchObj);
this._rebuildEntries();
this.initGUI();
}
private _rebuildEntries() {
this.batch.clear();
this.entries.length = 0;
const n = this.state.count;
const spread = this.state.spread;
const size = this.state.size;
this.basePositions = new Float32Array(n * 3);
this.phases = new Float32Array(n);
for (let i = 0; i < n; i++) {
const x = (Math.random() - 0.5) * spread;
const y = (Math.random() - 0.5) * spread * 0.6;
const z = (Math.random() - 0.5) * spread;
this.basePositions[i * 3 + 0] = x;
this.basePositions[i * 3 + 1] = y;
this.basePositions[i * 3 + 2] = z;
this.phases[i] = Math.random() * Math.PI * 2;
const entry = this.batch.add({
position: new Vector3(x, y, z),
size: new Vector2(size, size),
pivot: new Vector2(0.5, 0.5),
uvRect: new Vector4(0, 0, 1, 1),
});
this.entries.push(entry);
}
}
private _tmpPos = new Vector3(0, 0, 0);
private _tick() {
if (!this.state.animate || this.entries.length === 0) return;
const t = performance.now() * 0.001;
const base = this.basePositions;
for (let i = 0; i < this.entries.length; i++) {
const phase = this.phases[i];
const y = base[i * 3 + 1] + Math.sin(t + phase) * 0.5;
this._tmpPos.set(base[i * 3 + 0], y, base[i * 3 + 2]);
this.batch.update(this.entries[i], { position: this._tmpPos });
}
}
private initGUI() {
const folder = this.gui.addFolder('SpriteBatch');
folder.add(this.state, 'count', 100, 100_000, 100).onFinishChange(() => this._rebuildEntries());
folder.add(this.state, 'size', 0.05, 2, 0.05).onFinishChange(() => this._rebuildEntries());
folder.add(this.state, 'spread', 5, 100, 1).onFinishChange(() => this._rebuildEntries());
folder.addColor({ color: Object.values(this.state.tint).map((v) => v * 255) }, 'color').onChange((v) => {
this.batch.color = new Color().copyFromArray(v);
console.warn(this.batch.color);
});
folder.add(this.state, 'animate');
folder.open();
}
}
new Sample_Batch().run();
SpriteBatchis suitable for scenarios with "the same texture, massive numbers, and the need for unified management"; if each sprite requires independent component behavior (such as its own billboard), you should still use independentSpriteRenderers.

