Skip to content

公告板与距离恒定

精灵常用于标签、地标、血条等"始终面向相机、始终清晰可读"的场景。引擎通过 公告板组件 BillboardComponent距离恒定尺寸 distanceInvariantSize 两项能力来实现。

公告板(BillboardComponent)

公告板让物体始终朝向相机。它是一个独立组件,添加到精灵所在的 Object3D 上即可,与 SpriteRenderer 组合使用:

ts
import { Object3D, SpriteRenderer, BillboardComponent, BillboardType, Vector2 } from '@orillusion/core';

const obj = new Object3D();
const sprite = obj.addComponent(SpriteRenderer);
sprite.texture = tex;
scene.addChild(obj);
sprite.size = new Vector2(1.6, 1.6);

// 添加公告板,使其始终朝向相机
const billboard = obj.addComponent(BillboardComponent);
billboard.type = BillboardType.BillboardY;

BillboardType 取值:

类型说明
BillboardType.None不旋转,朝向由自身 Transform 决定
BillboardType.BillboardY仅绕 Y 轴朝向相机(保持竖直,适合地标、树木、人物标签)
BillboardType.BillboardXYZ完全朝向相机(任意角度都正对屏幕,适合图标、粒子贴片)

距离恒定尺寸

默认情况下精灵是世界空间面片,离相机越远在屏幕上越小。开启 distanceInvariantSize 后,精灵会保持恒定的屏幕尺寸,无论相机远近都一样大——非常适合地标图标(POI)、UI 提示等:

ts
sprite.distanceInvariantSize = true;

典型示例:地标(POI)

把"公告板 + 距离恒定 + 底边锚点"组合起来,就是一个经典的地标标记:始终正对相机、远近一样大、底部对齐到目标点。

ts
import { Object3D, SpriteRenderer, BillboardComponent, BillboardType, Color, Vector2 } from '@orillusion/core';

const poi = new Object3D();
poi.localPosition.set(10, 0, 5); // 标记的世界位置

const sprite = poi.addComponent(SpriteRenderer);
sprite.texture = iconTexture;
scene.addChild(poi);

sprite.size = new Vector2(1.6, 1.6);
sprite.pivot = new Vector2(0.5, 0);          // 底边中点对齐到位置
sprite.distanceInvariantSize = true;          // 远近一样大
sprite.color = new Color(1, 0.85, 0.4, 1);

poi.addComponent(BillboardComponent).type = BillboardType.BillboardY; // 始终朝向相机

示例

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

<
ts
import {AtmosphericComponent, BitmapTexture2D, BillboardComponent, BillboardType, BoxColliderShape, CameraUtil, Color, ColliderComponent, DirectLight, Engine3D, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, Object3D, PlaneGeometry, PointerEvent3D, Scene3D, SpriteRenderer, Vector2, Vector3, View3D } from "@orillusion/core";
import * as dat from 'dat.gui';

class Sample_POI {
    engine!: Engine3D;
    scene!: Scene3D;
    view!: View3D;
    lightObj!: Object3D;
    iconTexture!: BitmapTexture2D;
    gui!: dat.GUI;

    private pois: Object3D[] = [];

    private readonly state = {
        distanceInvariant: true,
        count: 8,
    };

    async run() {
        this.engine = await Engine3D.init({
            setting: {
                pick: { enable: true, mode: 'bound' as any },
            },
        });

        this.gui = new dat.GUI();

        this.scene = new Scene3D();
        let sky = this.scene.addComponent(AtmosphericComponent);

        let camera = CameraUtil.createCamera3DObject(this.scene);
        camera.perspective(60, this.engine.aspect, 0.1, 5000.0);
        camera.object3D.addComponent(HoverCameraController).setCamera(35, -30, 40);

        this.view = new View3D();
        this.view.scene = this.scene;
        this.view.camera = camera;
        this.view.enablePick = true;

        this.engine.startRenderView(this.view);

        await this.initScene();
        sky.relativeTransform = this.lightObj.transform;

        this.initGUI();
    }

    async initScene() {
        {
            this.lightObj = new Object3D();
            this.lightObj.rotationX = 45;
            this.lightObj.rotationY = 110;
            let lc = this.lightObj.addComponent(DirectLight);
            lc.lightColor = KelvinUtil.color_temperature_to_rgb(5355);
            lc.intensity = 3;
            this.scene.addChild(this.lightObj);
        }

        this.iconTexture = new BitmapTexture2D();
        this.iconTexture.flipY = true;
        await this.iconTexture.load('https://cdn.orillusion.com/textures/KB3D_NTT_Ads_basecolor.png');

        {
            const ground = new Object3D();
            const mr = ground.addComponent(MeshRenderer);
            mr.geometry = new PlaneGeometry(40, 40, 1, 1);
            const mat = new LitMaterial();
            mat.baseColor = new Color(0.25, 0.28, 0.32, 1);
            mr.material = mat;
            this.scene.addChild(ground);
        }

        this._rebuildPOIs();
    }

    private _rebuildPOIs() {
        for (const p of this.pois) p.removeFromParent();
        this.pois.length = 0;

        const n = this.state.count;
        for (let i = 0; i < n; i++) {
            const angle = (i / n) * Math.PI * 2;
            const radius = 4 + (i % 3) * 3;
            const x = Math.cos(angle) * radius;
            const z = Math.sin(angle) * radius;

            const poi = new Object3D();
            const sprite = poi.addComponent(SpriteRenderer);
            sprite.texture = this.iconTexture;
            sprite.size = new Vector2(1.6, 1.6);
            sprite.pivot = new Vector2(0.5, 0);
            sprite.distanceInvariantSize = this.state.distanceInvariant;
            sprite.color = new Color(1, 0.85, 0.4, 1);

            poi.addComponent(BillboardComponent).type = BillboardType.BillboardY;

            const collider = poi.addComponent(ColliderComponent);
            collider.shape = new BoxColliderShape().setFromCenterAndSize(
                new Vector3(0, 0.8, 0),
                new Vector3(1.6, 1.6, 0.2),
            );

            const id = i;
            poi.addEventListener(PointerEvent3D.PICK_CLICK, () => {
                console.log(`POI ${id} clicked (radius=${radius.toFixed(1)})`);
            }, this);

            poi.localPosition = new Vector3(x, 0, z);
            this.scene.addChild(poi);
            this.pois.push(poi);
        }
    }

    private initGUI() {
        const folder = this.gui.addFolder('POIs');
        folder.add(this.state, 'distanceInvariant').onChange((v: boolean) => {
            for (const p of this.pois) {
                const sprite = p.getComponent(SpriteRenderer) as SpriteRenderer | null;
                if (sprite) sprite.distanceInvariantSize = v;
            }
        });
        folder.add(this.state, 'count', 1, 24, 1).onFinishChange(() => this._rebuildPOIs());
        folder.open();
    }
}

new Sample_POI().run();