Appearance
多个相机的创建
fps: 0
点击运行
<template>
<div id="cameraMore">
<div class="flex space-between">
<div>fps: {{ fps }}</div>
<div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
</div>
<div v-if="isRunning">
<canvas id="cameraMore0"></canvas>
<canvas id="cameraMore1"></canvas>
<canvas id="cameraMore2"></canvas>
<canvas id="cameraMore3"></canvas>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import {
Engine,
Scene,
ArcRotateCamera,
Vector3,
HemisphericLight,
MeshBuilder,
PBRMetallicRoughnessMaterial
} from 'babylonjs'
let sceneResources: any
const fps = ref(0)
const isRunning = ref(false)
const onTrigger = async () => {
if (!isRunning.value) {
isRunning.value = true
await nextTick()
sceneResources = await initScene()
} else {
isRunning.value = false
destroy()
}
}
const initScene = async () => {
const ele = document.createElement('canvas') as HTMLCanvasElement
const div = document.getElementById('cameraMore') as HTMLElement
div.addEventListener('wheel', function(event) {
// 根据需要处理滚动
// 例如,可以修改相机的半径或角度
event.preventDefault() // 阻止默认滚动行为
})
const engine: any = new Engine(ele, true, {
preserveDrawingBuffer: true,
stencil: true,
disableWebGL2Support: false
})
engine.inputElement = document.getElementById('cameraMore0')
const scene = new Scene(engine)
scene.useRightHandedSystem = true
const camera0 = new ArcRotateCamera('Camera0', 0, 0.8, 5, new Vector3(0, 0, 0), scene)
camera0.setTarget(new Vector3(0, 0, 0))
camera0.lowerRadiusLimit = 4
camera0.upperRadiusLimit = 20
camera0.attachControl(document.getElementById('cameraMore0'), true)
const camera1 = new ArcRotateCamera('Camera1', 0, 0.8, 10, new Vector3(0, 0, 0), scene)
const camera2 = new ArcRotateCamera('Camera2', 0, 0.8, 10, new Vector3(0, 0, 0), scene)
const camera3 = new ArcRotateCamera('Camera3', 0, 0.8, 10, new Vector3(0, 0, 0), scene)
const createLight = () => {
const light = new HemisphericLight('light', new Vector3(1, 1, 0), scene)
return light
}
const createBox = () => {
const box = MeshBuilder.CreateBox('Box', {
size: 2
}, scene)
const mat = new PBRMetallicRoughnessMaterial('mat', scene)
mat.metallic = 1
mat.roughness = 0.5
box.material = mat
}
scene.createDefaultEnvironment()
engine.registerView(document.getElementById('cameraMore0'))
engine.registerView(document.getElementById('cameraMore1'), camera1)
engine.registerView(document.getElementById('cameraMore2'), camera2)
engine.registerView(document.getElementById('cameraMore3'), camera3)
let alpha = 0
scene.registerBeforeRender(() => {
camera1.radius = 10 + Math.cos(alpha) * 5
camera2.alpha += 0.01
camera3.beta = Math.cos(alpha)
alpha += 0.01
})
const runAnimate = () => {
engine.runRenderLoop(function() {
if (scene && scene.activeCamera) {
scene.render()
fps.value = engine.getFps().toFixed(2)
}
})
}
createLight()
createBox()
runAnimate()
return {
scene,
engine,
}
}
const destroy = () => {
if (sceneResources) {
sceneResources.engine.stopRenderLoop()
sceneResources.engine.dispose()
sceneResources.scene.dispose()
sceneResources = null
}
}
onMounted(async() => {
await nextTick()
})
onUnmounted(() => {
destroy()
})
</script>box 朝向和 camera 朝向一致
fps: 0
点击运行
<template>
<div>
<div class="flex space-between">
<div>fps: {{ fps }}</div>
<div @click="onTrigger" class="pointer">点击{{ !isRunning ? '运行' : '关闭' }}</div>
</div>
<canvas v-if="isRunning" id="boxFollowCamera" class="stage"></canvas>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import 'babylonjs-loaders'
import {
Engine,
Scene,
ArcRotateCamera,
Vector3,
StandardMaterial,
Color3,
Color4,
DirectionalLight,
MeshBuilder,
Axis,
Quaternion,
} from 'babylonjs'
import {
AdvancedDynamicTexture,
StackPanel,
Control,
TextBlock,
} from 'babylonjs-gui'
let sceneResources: any, adt: any
const fps = ref(0)
const isRunning = ref(false)
const isRightHandedSystem = false
const onTrigger = async () => {
if (!isRunning.value) {
isRunning.value = true
await nextTick()
sceneResources = await initScene()
} else {
isRunning.value = false
destroy()
}
}
const initScene = async () => {
const ele = document.getElementById("boxFollowCamera") as any
ele.addEventListener('wheel', function(event: any) {
// 根据需要处理滚动
// 例如,可以修改相机的半径或角度
event.preventDefault() // 阻止默认滚动行为
})
const engine: any = new Engine(ele, true, {
preserveDrawingBuffer: true,
stencil: true,
disableWebGL2Support: false
})
const scene: any = new Scene(engine)
// 使用左手坐标系
scene.useRightHandedSystem = isRightHandedSystem
const camera = new ArcRotateCamera('camera', 0, 0, 15, new Vector3(0, 0, 0), scene)
camera.upperBetaLimit = Math.PI / 2.2
camera.wheelPrecision = 30
camera.panningSensibility = 10
camera.attachControl(ele, true)
camera.setPosition(new Vector3(50, 50, 50))
const createAxis = () => {
const axisX = MeshBuilder.CreateLines(
'axisX', {
colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
},
scene
)
const axisY = MeshBuilder.CreateLines(
'axisY', {
colors: [new Color4(0, 1, 0, 1), new Color4(0, 1, 0, 1) ],
points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0) ]
},
scene
)
const axisZ = MeshBuilder.CreateLines(
'axisZ', {
colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
},
scene
)
return [axisX, axisY, axisZ]
}
const createGui = async () => {
adt = AdvancedDynamicTexture.CreateFullscreenUI('UI')
const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
xBox.position = new Vector3(80, 0, 0)
const xPanel = new StackPanel()
xPanel.width = '20px'
xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
const x = new TextBlock()
x.text = 'X'
x.height = '30px'
x.color = 'red'
adt.addControl(xPanel)
xPanel.addControl(x)
xPanel.linkWithMesh(xBox)
const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
yBox.position = new Vector3(0, 80, 0)
const yPanel = new StackPanel()
yPanel.width = '20px'
yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
const y = new TextBlock()
y.text = 'Y'
y.height = '30px'
y.color = 'green'
adt.addControl(yPanel)
yPanel.addControl(y)
yPanel.linkWithMesh(yBox)
const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
zBox.position = new Vector3(0, 0, 80)
const zPanel = new StackPanel()
zPanel.width = '20px'
zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
const z = new TextBlock()
z.text = 'Z'
z.height = '30px'
z.color = 'blue'
adt.addControl(zPanel)
zPanel.addControl(z)
zPanel.linkWithMesh(zBox)
}
const createLight = () => {
const light = new DirectionalLight('dir01', new Vector3(0, -1, -0.3), scene)
light.position = new Vector3(20, 60, 30)
return light
}
const createBox = () => {
const material = new StandardMaterial('m1')
material.emissiveColor = new Color3(0.2, 0.3, 0.4)
material.backFaceCulling = false
var box = MeshBuilder.CreateBox('box', {
width: 10,
height: 10,
depth: 1
}, scene)
box.material = material
box.position = new Vector3(10, 10, 10)
box.rotation.x = Math.PI / 2
return box
}
const runAnimate = () => {
engine.runRenderLoop(function() {
if (scene && scene.activeCamera) {
scene.render()
fps.value = engine.getFps().toFixed(2)
}
})
}
createAxis()
createGui()
createLight()
const box = createBox()
runAnimate()
scene.registerBeforeRender(() => {
// 获取相机的朝向向量
const cameraDirection = camera.getDirection(Axis.Z)
const rotationAxis = Vector3.Cross(Axis.Z, cameraDirection)
const rotationAngle = Math.acos(Vector3.Dot(Axis.Z, cameraDirection))
// BABYLON.Quaternion.RotationAxis 用于创建一个四元数(Quaternion),该四元数表示围绕指定轴旋转特定角度的旋转
const rotationQuaternion = Quaternion.RotationAxis(rotationAxis, rotationAngle)
// 将旋转应用到box
box.rotationQuaternion = rotationQuaternion
})
return {
scene,
engine,
}
}
const destroy = () => {
if (sceneResources) {
sceneResources.engine.stopRenderLoop()
sceneResources.engine.dispose()
sceneResources.scene.dispose()
sceneResources = null
}
if (adt) {
adt.dispose()
adt = null
}
}
onMounted(async() => {
await nextTick()
})
onUnmounted(() => {
destroy()
})
</script>烘焙渲染优化
fps: 0
点击运行
Selected:-
Bake Status:-
Update Status:-
Visible Count:- / 10000
Cell Info:-
<template>
<div>
<div class="flex space-between">
<div>fps: {{ fps }}</div>
<div
@click="onTrigger"
class="pointer"
>
点击{{ !isRunning ? '运行' : '关闭' }}
</div>
</div>
<div>Selected:{{ selectedInfo || '-' }}</div>
<div>Bake Status:{{ bakeStatus || '-' }}</div>
<div>Update Status:{{ updateStatus || '-' }}</div>
<div>Visible Count:{{ visibleCount || '-' }} / 10000</div>
<div>Cell Info:{{ cellInfo || '-' }}</div>
<canvas
v-if="isRunning"
id="renderingOptimization"
class="stage"
></canvas>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, nextTick, onUnmounted } from 'vue'
import 'babylonjs-loaders'
import {
Engine,
Scene,
ArcRotateCamera,
Vector3,
Color4,
DirectionalLight,
MeshBuilder,
Color3,
StandardMaterial,
Material,
PointerEventTypes
} from 'babylonjs'
import {
AdvancedDynamicTexture,
StackPanel,
Control,
TextBlock,
Rectangle,
TextWrapping
} from 'babylonjs-gui'
let sceneResources: any, adt: any
const fps = ref(0)
const isRunning = ref(false)
const selectedInfo = ref('')
const bakeStatus = ref('')
const updateStatus = ref('')
const visibleCount = ref(0)
const cellInfo = ref('')
const isRightHandedSystem = false
const onTrigger = async () => {
if (!isRunning.value) {
isRunning.value = true
await nextTick()
sceneResources = await initScene()
} else {
isRunning.value = false
destroy()
}
}
const initScene = async () => {
const ele = document.getElementById('renderingOptimization') as any
ele.addEventListener('wheel', function (event: any) {
// 根据需要处理滚动
// 例如,可以修改相机的半径或角度
event.preventDefault() // 阻止默认滚动行为
})
const engine: any = new Engine(ele, true, {
preserveDrawingBuffer: true,
stencil: true,
disableWebGL2Support: false
})
const scene: any = new Scene(engine)
// 使用左手坐标系
scene.useRightHandedSystem = isRightHandedSystem
const camera: any = new ArcRotateCamera(
'camera',
0,
0,
100,
new Vector3(0, 0, 0),
scene
)
camera.upperBetaLimit = Math.PI / 2.2
camera.wheelPrecision = 30
camera.panningSensibility = 10
camera.attachControl(ele, true)
camera.setPosition(new Vector3(50, 50, 50))
const createAxis = () => {
const axisX = MeshBuilder.CreateLines(
'axisX',
{
colors: [new Color4(1, 0, 0, 1), new Color4(1, 0, 0, 1)],
points: [new Vector3(0, 0, 0), new Vector3(80, 0, 0)]
},
scene
)
const axisY = MeshBuilder.CreateLines(
'axisY',
{
colors: [new Color4(0, 1, 0, 1), new Color4(0, 1, 0, 1)],
points: [new Vector3(0, 0, 0), new Vector3(0, 80, 0)]
},
scene
)
const axisZ = MeshBuilder.CreateLines(
'axisZ',
{
colors: [new Color4(0, 0, 1, 1), new Color4(0, 0, 1, 1)],
points: [new Vector3(0, 0, 0), new Vector3(0, 0, 80)]
},
scene
)
return [axisX, axisY, axisZ]
}
const createGui = async () => {
adt = AdvancedDynamicTexture.CreateFullscreenUI('UI', true, scene)
const xBox = MeshBuilder.CreateBox('x', { size: 1 }, scene)
xBox.position = new Vector3(80, 0, 0)
const xPanel = new StackPanel()
xPanel.width = '20px'
xPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
xPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
const x = new TextBlock()
x.text = 'X'
x.height = '30px'
x.color = 'red'
adt.addControl(xPanel)
xPanel.addControl(x)
xPanel.linkWithMesh(xBox)
const yBox = MeshBuilder.CreateBox('y', { size: 1 }, scene)
yBox.position = new Vector3(0, 80, 0)
const yPanel = new StackPanel()
yPanel.width = '20px'
yPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
yPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
const y = new TextBlock()
y.text = 'Y'
y.height = '30px'
y.color = 'green'
adt.addControl(yPanel)
yPanel.addControl(y)
yPanel.linkWithMesh(yBox)
const zBox = MeshBuilder.CreateBox('z', { size: 1 }, scene)
zBox.position = new Vector3(0, 0, 80)
const zPanel = new StackPanel()
zPanel.width = '20px'
zPanel.horizontalAlignment = Control.HORIZONTAL_ALIGNMENT_RIGHT
zPanel.verticalAlignment = Control.VERTICAL_ALIGNMENT_BOTTOM
const z = new TextBlock()
z.text = 'Z'
z.height = '30px'
z.color = 'blue'
adt.addControl(zPanel)
zPanel.addControl(z)
zPanel.linkWithMesh(zBox)
}
const createLight = () => {
const light = new DirectionalLight('dir01', new Vector3(0, -1, -0.3), scene)
light.position = new Vector3(20, 60, 30)
return light
}
const createBakingEffect = () => {
// ── 材质(5种颜色模拟"不同类型"的物体)────────────────────────────
// 每种颜色对应一个 StandardMaterial,共享给同类型的实例
// 好处:5种材质 = 最多5个 draw call batches(相比10000个独立材质)
const colors = [
new Color3(0.2, 0.6, 1.0), // 蓝色
new Color3(1.0, 0.4, 0.2), // 橙色
new Color3(0.2, 0.9, 0.3), // 绿色
new Color3(0.9, 0.8, 0.1), // 黄色
new Color3(0.7, 0.2, 0.9) // 紫色
]
// 为每种颜色创建一个 base mesh(共享几何体的模板)
// base mesh 自身设为不可见,只作为 createInstance 的几何体来源
const bases = colors.map((c, i) => {
const mat = new StandardMaterial('mat' + i, scene)
mat.diffuseColor = c // 漫反射颜色
mat.alpha = 1.0 // 完全不透明
mat.transparencyMode = Material.MATERIAL_OPAQUE // 不透明模式,禁用透明度排序
const b = MeshBuilder.CreateSphere(
'base' + i,
{ diameter: 0.6, segments: 8 }, // 直径0.6,8段(低多边形,性能优先)
scene
)
b.material = mat
b.isVisible = false // base mesh 本身不渲染,仅作为 createInstance 的几何体模板
return b
})
// ── 创建 10000 个球体实例 ────────────────────────────────────────
const sphereCount = 10000 // 总球体数量
const instances: any = [] // 存储所有 InstancedMesh,用于运行时控制可见性
// 使用 Float32Array 存储所有球体坐标(比 Vector3 对象节省内存,烘焙计算更快)
// 布局:[x0, y0, z0, x1, y1, z1, ...]
const positions = new Float32Array(sphereCount * 3)
for (let i = 0; i < sphereCount; i++) {
// 在 X/Y 各 ±20 范围内随机分布,Z 在 -10 到 -30 之间(位于遮挡物后方)
const x = (Math.random() - 0.5) * 40
const y = (Math.random() - 0.5) * 40
const z = -10 - Math.random() * 20 // Z: -10 ~ -30
// 从对应颜色的 base mesh 创建实例(共享几何体,各自独立变换/可见性)
const inst = bases[i % 5].createInstance('sphere_' + i) // 使用createInstance创建实例,如果使用clone则只有20不到的帧率,除非被遮挡了才会恢复60帧率
inst.position.set(x, y, z) // 设置实例位置
inst.freezeWorldMatrix() // 球体不会移动,冻结变换矩阵避免每帧重新计算
instances.push(inst) // 加入列表备用
// 将坐标写入 Float32Array(用于烘焙阶段的快速批量计算)
positions[i * 3] = x
positions[i * 3 + 1] = y
positions[i * 3 + 2] = z
}
// ── 遮挡长方体(Occluder)──────────────────────────────────────────
// 位于 Z=5,球体在 Z=-10~-30,相机在 Z>0 时长方体会挡住所有球
const occluder = MeshBuilder.CreateBox(
'occluder',
{ width: 50, height: 50, depth: 2 }, // 50×50 的薄平板
scene
)
occluder.position.set(0, 0, 5) // 放置在 Z=5
const occMat = new StandardMaterial('occMat', scene)
occMat.diffuseColor = new Color3(0.8, 0.2, 0.2) // 红色,便于辨认
occMat.alpha = 1.0
occMat.transparencyMode = Material.MATERIAL_OPAQUE
occluder.material = occMat
// ── GUI 悬浮标签 ─────────────────────────────────────────────────
// 复用 createGui() 已创建的全屏 GUI 层,避免重复创建
// 标签基准尺寸(对应相机距离 BASE_DIST 时的大小)
const BASE_DIST = 80 // 基准距离,与相机初始 radius 一致
const BASE_W = 180 // 基准宽度(px)
const BASE_H = 44 // 基准高度(px)
const BASE_FONT = 11 // 基准字号(px)
const BASE_OFFSET = -40 // 基准 Y 偏移(px,负数 = 向上)
// 当前被选中的球体实例(null 表示无选中),用于每帧计算距离
let selectedMesh: any = null
// 标签背景容器(Rectangle = 带边框的矩形)
const label = new Rectangle()
label.width = '180px'
label.height = '44px'
label.cornerRadius = 6 // 圆角
label.color = '#7ef' // 边框颜色
label.thickness = 1 // 边框宽度
label.background = 'rgba(0,0,0,0.82)' // 半透明黑色背景
label.isVisible = false // 初始隐藏,点击后才显示
adt.addControl(label)
// 标签内的文字控件
const labelText = new TextBlock()
labelText.color = '#fff'
labelText.fontSize = 11
labelText.fontFamily = 'monospace'
labelText.lineSpacing = '4px'
labelText.textWrapping = TextWrapping.WordWrap
label.addControl(labelText)
// ── 点击事件(场景级别 picking)──────────────────────────────────
// 用一个场景级监听器代替 10000 个独立 ActionManager,性能更高
scene.onPointerObservable.add((info: any) => {
// 只处理 POINTERPICK 事件(鼠标点击拾取)
if (info.type !== PointerEventTypes.POINTERPICK) return
const mesh = info.pickInfo.pickedMesh // 被点击的 mesh(可能是 InstancedMesh)
// 判断是否点中了球体实例(名称以 'sphere_' 开头)
if (mesh && mesh.name.startsWith('sphere_')) {
// 从名称中解析出索引,推算其颜色类型并显示
const idx = parseInt(mesh.name.split('_')[1])
const colorHex = colors[idx % 5].toHexString()
// 更新标签文字(名称 + 颜色)
labelText.text = mesh.name + '\n' + colorHex
// linkWithMesh:让标签每帧自动跟随该 mesh 的屏幕投影位置
label.linkWithMesh(mesh)
label.isVisible = true
selectedMesh = mesh // 记录选中对象,供 registerBeforeRender 计算距离
selectedInfo.value = mesh.name + ' | ' + colorHex
} else {
// 点击空白处:隐藏标签并解除绑定
label.isVisible = false
label.linkWithMesh(null)
selectedMesh = null // 清除选中,停止每帧缩放计算
}
})
// ════════════════════════════════════════════════════════════════
// BAKED OCCLUSION CULLING(烘焙遮挡剔除)
//
// 核心思路:
// 1. [烘焙阶段] 启动时把相机空间划分为 GRID×GRID 个格子,
// 为每个格子预计算"从该区域哪些球可见",结果存入 visibilityCache。
// 2. [运行时] 每帧判断相机落在哪个格子,直接查表得到可见列表,
// 用差量算法只更新"发生变化"的实例 isVisible,开销接近零。
//
// 保守策略(避免误隐藏):
// 每个格子取4个角点采样,球必须从【所有角点】都被遮挡才标为不可见。
// 这样即使相机在格子边缘,也不会出现"长方体还没挡住却已消失"的瑕疵。
// ════════════════════════════════════════════════════════════════
const GRID = 16 // 将相机角度空间划分为 16×16 = 256 个格子
const OCC_Z = 5 // 遮挡平面的 Z 坐标(与 occluder.position.z 一致)
const HALF_W = 25 // 遮挡物在 X 方向的半宽(width/2 = 25)occluder.width/2
const HALF_H = 25 // 遮挡物在 Y 方向的半高(height/2 = 25)occluder.height/2
let visibilityCache: any = null // 烘焙结果:Map<cellKey(number), Uint16Array(可见球索引)>
let lastCellKey: any = -1 // 上一帧相机所在的格子 key,-1 表示尚未初始化
let lastVisible: any = null // 上一帧的可见列表,用于差量计算
/**
* 判断球体是否被遮挡物遮挡(从给定相机位置观察)
* 原理:将相机到球体的射线与 Z=OCC_Z 平面求交,
* 若交点落在遮挡物范围内,则球体被遮挡。
* @param {number} px/py/pz - 球体世界坐标
* @param {number} cx/cy/cz - 相机世界坐标
* @returns {boolean} true 表示被遮挡(不可见)
*/
const isOccluded = (
px: number,
py: number,
pz: number,
cx: number,
cy: number,
cz: number
): boolean => {
// 相机在遮挡平面后方,或球体在遮挡平面前方:不可能被遮挡
if (cz <= OCC_Z || pz >= OCC_Z) return false
// 参数 t:射线从相机到球体,t=0 在相机处,t=1 在球体处
// 求射线与 z=OCC_Z 平面的交点参数
const t = (OCC_Z - cz) / (pz - cz)
// 交点在遮挡平面上的 X/Y 坐标(线性插值)
const projX = cx + t * (px - cx)
const projY = cy + t * (py - cy)
// 判断投影点是否落在遮挡物的矩形范围内
return (
projX > -HALF_W && projX < HALF_W && projY > -HALF_H && projY < HALF_H
)
}
/**
* 将相机位置映射到格子编号(整数 key)
* 用球坐标:水平角(angle) + 仰角(elev) → 格子行列 → 一维整数 key
* 用整数 key 比字符串 key 在 Map 查找中更快
* @param {BABYLON.Vector3} pos - 相机当前世界坐标
* @returns {number} 格子编号(0 ~ GRID²-1)
*/
const getCellKey = (pos: Vector3): number => {
// 水平角:atan2(x, z),范围 -π ~ π
const angle = Math.atan2(pos.x, pos.z)
// 仰角:atan2(y, xz平面距离),范围 -π/2 ~ π/2
const elev = Math.atan2(pos.y, Math.sqrt(pos.x * pos.x + pos.z * pos.z))
// 将水平角映射到 [0, GRID) 的整数列索引
const gx = Math.max(
0,
Math.min(
GRID - 1,
Math.floor(((angle + Math.PI) / (2 * Math.PI)) * GRID)
)
)
// 将仰角映射到 [0, GRID) 的整数行索引
const gy = Math.max(
0,
Math.min(GRID - 1, Math.floor(((elev + Math.PI / 2) / Math.PI) * GRID))
)
// 合并为一维整数 key(行优先)
return gx * GRID + gy
}
/**
* 根据格子 key 更新所有球体实例的 isVisible
*
* 差量更新算法:
* 比较新旧可见列表,只对"状态发生变化"的实例进行设置
* 避免每次全量遍历 10000 个实例(当相机缓慢移动时效率极高)
*
* @param {number} cellKey - 当前相机所在格子的编号
*/
const applyVisibility = (cellKey: number) => {
if (!visibilityCache) return // 烘焙未完成,跳过
const newVisible = visibilityCache.get(cellKey) // 新格子的可见列表
if (!newVisible) return // 格子不存在(理论上不会发生)
if (lastVisible === null) {
// 首次调用:没有上一帧数据,全量初始化
for (let i = 0; i < sphereCount; i++) instances[i].isVisible = false // 全部先隐藏
for (let i = 0; i < newVisible.length; i++)
instances[newVisible[i]].isVisible = true // 再显示可见的
} else {
// 后续帧:差量更新,只改变需要切换状态的实例
const newSet = new Set(newVisible) // 新可见集合(用 Set 做 O(1) 查找)
const oldSet = new Set(lastVisible) // 旧可见集合
// 遍历旧可见列表:不在新列表中的 → 变为不可见
for (let i = 0; i < lastVisible.length; i++) {
if (!newSet.has(lastVisible[i]))
instances[lastVisible[i]].isVisible = false
}
// 遍历新可见列表:不在旧列表中的 → 变为可见
for (let i = 0; i < newVisible.length; i++) {
if (!oldSet.has(newVisible[i]))
instances[newVisible[i]].isVisible = true
}
}
lastCellKey = cellKey // 记录当前格子 key,供下帧比较
lastVisible = newVisible // 记录当前可见列表,供下帧差量计算
// 更新 UI 显示
visibleCount.value = newVisible.length
cellInfo.value = Math.floor(cellKey / GRID) + '_' + (cellKey % GRID)
}
/**
* 烘焙遮挡数据(异步,启动时运行一次)
*
* 遍历所有 GRID×GRID 个格子,为每个格子:
* 1. 计算格子4个角点对应的相机采样位置
* 2. 对每个球体判断:若从所有4个角点都被遮挡 → 该球在此格子不可见
* 3. 把可见球的索引存为 Uint16Array,写入 visibilityCache
*
* 异步分帧执行(每隔2列 yield 一次),避免烘焙期间页面卡死
*/
const bakeOcclusion = async () => {
const cache = new Map() // 临时存储烘焙结果
const R = 80 // 采样用的相机距原点距离(与实际相机初始半径匹配)
for (let gx = 0; gx < GRID; gx++) {
for (let gy = 0; gy < GRID; gy++) {
// ── 计算格子4个角点的相机采样位置 ──────────────────────────
// 保守策略:取角点而非中心,确保格子内任意相机位置都不会产生误隐藏
const samples = [] // 4个采样点的 [cx, cy, cz] 数组
for (let sx = 0; sx <= 1; sx++) {
// sx=0: 左边界, sx=1: 右边界
for (let sy = 0; sy <= 1; sy++) {
// sy=0: 下边界, sy=1: 上边界
// 角点对应的水平角(从格子边界索引反算弧度)
const angle = ((gx + sx) / GRID) * 2 * Math.PI - Math.PI
// 角点对应的仰角
const elev = ((gy + sy) / GRID) * Math.PI - Math.PI / 2
// 将球坐标(R, angle, elev)转换为笛卡尔坐标(cx, cy, cz)
samples.push([
R * Math.sin(angle) * Math.cos(elev), // cx
R * Math.sin(elev), // cy
R * Math.cos(angle) * Math.cos(elev) // cz
])
}
}
// ── 遍历所有球体,判断在此格子中是否可见 ────────────────────
const visible = [] // 在此格子可见的球体索引列表
for (let i = 0; i < sphereCount; i++) {
// 从 Float32Array 读取球体坐标(比访问 Vector3 对象快)
const px = positions[i * 3],
py = positions[i * 3 + 1],
pz = positions[i * 3 + 2]
// 保守判断:只有从4个角点【全部】都被遮挡,才认为不可见
let occludedFromAll = true
for (let s = 0; s < 4; s++) {
if (
!isOccluded(
px,
py,
pz,
samples[s][0],
samples[s][1],
samples[s][2]
)
) {
occludedFromAll = false // 有任意角点能看见,则此球可见
break // 提前退出,不需要检查剩余角点
}
}
// 可见的球加入列表
if (!occludedFromAll) visible.push(i)
}
// 用 Uint16Array 存储索引(每个索引2字节,最大值9999 < 65535)
// 相比普通 Array 节省约3倍内存
cache.set(gx * GRID + gy, new Uint16Array(visible))
}
// 每处理2列 yield 一次,将控制权交还给浏览器事件循环
// 这样渲染循环可以继续运行,页面不会在烘焙期间冻结
if (gx % 2 === 1) {
await new Promise((r) => setTimeout(r, 0))
// 更新进度显示(百分比)
updateStatus.value =
'Baking ' + Math.round(((gx + 1) / GRID) * 100) + '%...'
}
}
// 烘焙完成,将结果写入全局 cache
visibilityCache = cache
bakeStatus.value = 'Ready'
// 立即根据当前相机位置应用可见性(不等到下一帧)
applyVisibility(getCellKey(camera.position))
}
// 每帧执行:检查相机是否跨越了格子边界
// 只有跨格子时才调用 applyVisibility,否则直接跳过(每帧仅一次整数比较)
scene.registerBeforeRender(() => {
// ① 遮挡剔除:只有相机跨格子才更新可见性
if (visibilityCache) {
const cellKey = getCellKey(camera.position)
if (cellKey !== lastCellKey) applyVisibility(cellKey)
}
// ② 标签缩放:相机离球越近标签越大,越远越小
if (!selectedMesh || !label.isVisible) return
// 计算相机到选中球体的欧氏距离
const dist = Vector3.Distance(camera.position, selectedMesh.position)
// 线性缩放比例:基准距离/当前距离,并限制在 [0.3, 2.5] 区间防止极端值
const scale = Math.max(0.3, Math.min(2.5, BASE_DIST / dist))
// 同步缩放宽高、字号、Y偏移(保持标签始终贴在球体正上方)
label.width = Math.round(BASE_W * scale) + 'px'
label.height = Math.round(BASE_H * scale) + 'px'
label.linkOffsetY = Math.round(BASE_OFFSET * scale)
labelText.fontSize = Math.max(8, Math.round(BASE_FONT * scale))
})
// 启动异步烘焙(不阻塞当前帧的渲染)
bakeOcclusion()
}
const runAnimate = () => {
engine.runRenderLoop(function () {
if (scene && scene.activeCamera) {
scene.render()
fps.value = engine.getFps().toFixed(2)
}
})
}
createAxis()
createGui()
createLight()
createBakingEffect()
runAnimate()
// scene.registerBeforeRender(() => {})
return {
scene,
engine
}
}
const destroy = () => {
if (sceneResources) {
sceneResources.engine.stopRenderLoop()
sceneResources.engine.dispose()
sceneResources.scene.dispose()
sceneResources = null
}
if (adt) {
adt.dispose()
adt = null
}
}
onMounted(async () => {
await nextTick()
})
onUnmounted(() => {
destroy()
})
</script>