Skip to content

图集与批量渲染

当多个精灵共用同一张贴图(图集 / 精灵表),或需要绘制成千上万个精灵时,可以用 UV 子区域Sprite 资源SpriteBatch 批量渲染 来组织资源、提升性能。

UV 子区域(图集)

一张图集(atlas / spritesheet)上排布了多个小图。通过 SpriteRenderer.uvRect 指定要采样的子区域 (x, y, w, h)(均为 0~1 的归一化坐标),即可只显示图集中的某一格:

ts
import { Vector4 } from '@orillusion/core';

// 采样图集左上角 1/4 区域
sprite.texture = atlasTexture;
sprite.uvRect = new Vector4(0, 0, 0.5, 0.5);

Sprite 资源

Sprite 是一个可复用的精灵资源对象,把"贴图 + 子区域 + 锚点"打包在一起,方便在多个 SpriteRenderer 间共享:

ts
import { Sprite, Vector4, Vector2 } from '@orillusion/core';

// 方式一:从贴图快速创建
const sprite1 = Sprite.fromTexture(atlasTexture, 'icon');

// 方式二:构造时指定图集子区域与锚点
const sprite2 = new Sprite({
    texture: atlasTexture,
    region: new Vector4(0.5, 0, 0.5, 0.5), // 图集中的子区域
    pivot: new Vector2(0.5, 0.5),
    name: 'coin',
});

// 赋给渲染器
spriteRenderer.sprite = sprite2;
成员类型说明
textureTexture贴图
regionVector4图集子区域 (x, y, w, h),归一化坐标
pivotVector2锚点(0~1)
Sprite.fromTexture(tex, name?)静态用整张贴图快速创建一个 Sprite

示例

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
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();

批量渲染(SpriteBatch)

当需要绘制大量共享同一张贴图的精灵(如粒子贴片、海量图标、弹幕)时,逐个 SpriteRenderer 会产生大量 draw call。SpriteBatch 把它们合并到一次绘制中,显著提升性能。

ts
import { Object3D, SpriteBatch, Color, Vector2, Vector3, Vector4 } from '@orillusion/core';

const batchObj = new Object3D();
const batch = batchObj.addComponent(SpriteBatch);
batch.texture = tex;                 // 整批共享一张贴图
batch.color = new Color(1, 1, 1, 1); // 整批叠加色
scene.addChild(batchObj);

// 逐个添加条目,add() 返回一个可后续更新的句柄
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),  // 图集子区域
});

SpriteBatch 常用方法:

方法说明
add(spec)添加一个精灵条目,返回 SpriteBatchEntry 句柄
update(entry, patch)更新某条目的 position / size / pivot / uvRect
remove(entry)移除某条目
clear()清空所有条目
entries只读,当前所有条目

运行时更新条目(例如让每个精灵浮动):

ts
batch.update(entry, { position: new Vector3(x, newY, z) });

SpriteBatch 适合"同贴图、海量、需整体管理"的场景;若每个精灵需要独立的组件行为(如各自的公告板),仍应使用独立的 SpriteRenderer

示例

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
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();