Apple 2025年度發佈會LOGO以標誌性的蘋果圖形被注入熾熱的火焰質感,色彩從暖調橙黃向冷調湛藍自然過渡,似高温灼燒下的金屬表面,迸發出熔融的光澤;又若無形的能量在流動,勾勒出科技的脈搏與律動,將 “科技” 與 “力量” 的碰撞感具象化,光影的明暗交錯削弱了平面的單薄感,賦予其近乎觸手可及的質感,同時營造出濃郁的未來感與未知感。
摘要
如上述引用內容,本文將基於 React + Three.js + GLSL 的相關知識,實現 Apple 2025 動態熱成像 logo 效果。通過本文的閲讀和學習,你將學習到的知識點包括:離屏渲染技術 FBO、交互事件與動態參數控制、Leva 控制面板的應用、視頻紋理、遮罩紋理、着色器材質的使用、熱成像動畫着色器實現和應用等。
效果
本文頁面實現效果如下圖所示,頁面頁面中心由 Apple 熱成像動態圖標構成,圖標上面由橙色和藍色漸變色動態流動,頁面底部為藍色漸變文案。
當使用鼠標 🖱️ 或觸控板 👋 網頁上按壓或拖動 Logo 時,可以看到顏色隨手勢展開變化,看起來像是模擬真實熱量軌跡。
本專欄系列代碼託管在 Github 倉庫【threejs-odessey】,後續所有目錄也都將在此倉庫中更新。
🔗 代碼倉庫地址:git@github.com:dragonir/threejs-odessey.git
實現
本文代碼實現效果參考自:https://github.com/vladmdgolam/apple-event-2025,實現內容模塊旨在對其核心知識點進行彙總歸納學習,通過相同的原理並舉一反三,實現專屬自己的熱成像動態 logo 😎。
① 資源引入
以下是實現蘋果熱成像所需的主要依賴資源,其中:OrthographicCamera用於創建平行投影相機、LinearFilter 是紋理採樣過濾方式的常量,用於在控制紋理在放大縮小時的平滑過渡效果、ShaderMaterial 用於通過 GLSL創建自定義的着色器材質,是實現本案例效果的關鍵、VideoTexture可以將視頻元素作為數據源創建動態的視頻紋理、Leva 是一個輕量級的前端調試工具庫,主要用於快速創建交互式控制面板,方便開發者在開發過程中實時調試熱成像的各種參數等。其他的依賴都是創建三維場景必須的一些內容,具體作用可自行查閲。
import { OrthographicCamera, DoubleSide, LinearFilter, Mesh, RGBFormat, RepeatWrapping, ShaderMaterial, Texture, TextureLoader, VideoTexture, } from "three"
import { Leva, levaStore, useControls } from "leva"
② 頁面場景初始化 HeatmapScene
使用 React Three Fiber 初始化場景、相機等,其中 Leva 組件用於動態可視化調試着色器的多種參數,Scene 組件用於渲染 logo 場景,是整個交互可視化效果的核心統籌層。
return (
<div>
<Leva hidden={levaHidden} />
<InfoPanel onToggleControls={() => setLevaHidden((p) => !p)} onRandomizeColors={randomizeColors} />
<div ref={containerRef} className="w-[560px] h-[560px] touch-none select-none">
<Canvas
orthographic
camera={{ position: [0, 0, 1], left: -2, right: 2, top: 2, bottom: -2, near: -1, far: 1, }}
gl={{ antialias: true, alpha: true, outputColorSpace: "srgb" }}
flat
>
<Scene containerRef={containerRef} />
</Canvas>
</div>
<div className="dragonir">@dragonir</div>
</div>
)
③ 實現動態熱力圖網格 HeatMesh
HeatMesh 組件,它主要通過視頻紋理 VideoTexture、繪製紋理 drawTexture 和遮罩紋理 maskTexture 作為數據源,使用着色器材質 ShaderMaterial 渲染一個平面網格 planeGeometr,實現了可實時調整的熱力圖效果。ShaderMaterial 通過傳入自定義頂點着色器和片元着色器實現複雜的熱力圖色彩映射和動態效果。
export const HeatMesh = ({ drawTexture }: { drawTexture: Texture | null }) => {
const timeRef = useRef(0)
const videoRef = useRef<HTMLVideoElement | null>(null)
const [videoTexture, setVideoTexture] = useState<VideoTexture | null>(null)
// Leva 控制面板着色器參數:power(強度)、opacity(透明度)、顏色映射、混合與過渡等參數可實時調整。
const { power, opacity, color1, blend1, fade1,maxBlend4 ...} = useControls("Heat Map", {})
// 遮罩紋理
const maskTexture = useLoader(TextureLoader, "/logo.png")
useEffect(() => {
if (maskTexture) {
maskTexture.wrapS = maskTexture.wrapT = RepeatWrapping
maskTexture.needsUpdate = true
}
}, [maskTexture])
// 視頻紋理
useEffect(() => {
const video = document.createElement("video")
video.src = "/apple.mp4"
video.loop = true
video.playsInline = true
video.autoplay = true
video.preload = "auto"
const onVideoLoad = () => {
const texture = new VideoTexture(video)
texture.minFilter = LinearFilter
texture.magFilter = LinearFilter
texture.format = RGBFormat
setVideoTexture(texture)
}
}, [])
// 着色器材質
const material = useMemo(() => {
return new ShaderMaterial({
uniforms: {
blendVideo: { value: 1.0 },
drawMap: { value: drawTexture },
textureMap: { value: videoTexture || maskTexture },
maskMap: { value: maskTexture },
opacity: { value: opacity },
amount: { value: 1.0 },
color1: { value: color1 },
blend: { value: [blend1, blend2, blend3, blend4] },
fade: { value: [fade1, fade2, fade3, fade4] },
power: { value: power },
rnd: { value: 0 },
maxBlend: { value: [maxBlend1, maxBlend2, maxBlend3, maxBlend4] },
heat: { value: [0, 0, 0, 1.02] },
stretch: { value: [1, 1, 0, 0] },
},
vertexShader: heatVertexShader,
fragmentShader: heatFragmentShader,
transparent: true,
side: DoubleSide,
})
}, [...])
// 動態更新與渲染,通過 useFrame 鈎子每幀更新時間和隨機值,使熱力圖呈現動態變化
useFrame((_, delta) => {
timeRef.current += delta
if (material) {
material.uniforms.rnd.value = Math.random()
material.uniforms.amount.value = 1.0
}
})
// 渲染一個平面網格,應用自定義着色器材質,作為熱力圖的載體
return (
<mesh>
<planeGeometry />
<primitive object={material} />
</mesh>
)
}
其中 heatVertexShader 和 heatFragmentShader 是着色器材質的頂點着色器和片元着色器,它們的詳細內容見文章最後的着色器模塊。
- 頂點着色器
heatVertexShader:處理網格頂點的位置變換 - 片元着色器
heatFragmentShader:根據輸入紋理的像素值,結合顏色映射參數,計算每個像素的最終顏色,實現熱力圖效果。
遮罩紋理圖片預覽
④ 實現繪製渲染器組件 DrawRenderer
DrawRenderer 組件的主要作用是實時處理動態繪製輸入,通過雙 FBO交替渲染機制,通過緩衝和自定義着色器實現渲染累積與漸隱效果,並接收外部輸入的繪製位置、方向、強度等參數,通過自定義着色器實時更新繪製紋理,並將結果傳遞給外部使用。
// 通過引入 useFBO 創建幀緩衝對象,用於在GPU上存儲和處理繪製紋理。
import { useFBO } from "@react-three/drei"
const fboParams = {
type: FloatType,
format: RGBAFormat,
minFilter: LinearFilter,
magFilter: LinearFilter,
}
export const DrawRenderer = ({ size = 256, position, direction, drawAmount, onTextureUpdate, sizeDamping, fadeDamping, radiusSize }) => {
const { size: canvasSize } = useThree()
const dynamicRadius = radiusSize
const fboA = useFBO(size, size, fboParams)
const fboB = useFBO(size, size, fboParams)
const renderTargets = useMemo(() => ({
current: fboA,
previous: fboB
}), [fboA, fboB])
const { drawScene, drawCamera, material } = useMemo(() => {
const drawScene = new Scene()
const drawCamera = new OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.1, 10)
drawCamera.position.z = 1
// 通過 ShaderMaterial 定義繪製的核心邏輯,着色器接收外部參數並更新 FBO 紋理
const material = new ShaderMaterial({
uniforms: {
uRadius: { value: [-8, 0.9, dynamicRadius] },
uPosition: { value: [0, 0] },
uDirection: { value: [0, 0, 0, 0] },
uResolution: { value: [canvasSize.width, canvasSize.height, 1] },
uTexture: { value: renderTargets.previous.texture },
uSizeDamping: { value: sizeDamping },
uFadeDamping: { value: fadeDamping },
uDraw: { value: 0 },
},
// 處理平面頂點的座標轉換,確保與 FBO 紋理座標對齊
vertexShader: drawVertexShader,
// 根據輸入的 uPosition uRadius等參數,在上一幀紋理uTexture的基礎上繪製新的漸變,並應用衰減uFadeDamping使舊漸變漸消失,實現動態流動效果
fragmentShader: drawFragmentShader,
depthTest: false,
transparent: true,
})
// 創建一個平面網格,作為繪製的畫布
const mesh = new Mesh(new PlaneGeometry(1, 1), material)
drawScene.add(mesh)
return { drawScene, drawCamera, material }
}, [renderTargets, dynamicRadius, sizeDamping, fadeDamping, canvasSize])
// Update 着色器變量參數同步:通過 useEffect 將外部傳入的 position、direction、drawAmount等參數實時更新到着色器的 uniforms 中
useEffect(() => {
material.uniforms.uRadius.value[2] = dynamicRadius
material.uniforms.uPosition.value = position
material.uniforms.uDirection.value = direction
material.uniforms.uDraw.value = drawAmount
}, [material, dynamicRadius, position, direction, drawAmount])
// 幀循環:每幀執行以下操作:將上一幀的FBO紋理previous作為輸入傳遞給着色器;切換渲染目標到當前FBO current,渲染繪製場景;交換current和previous的角色,準備下一幀的累積;
useFrame(({ gl }) => {
const currentTarget = renderTargets.current
const previousTarget = renderTargets.previous
material.uniforms.uTexture.value = previousTarget.texture
const originalTarget = gl.getRenderTarget()
gl.setRenderTarget(currentTarget)
gl.clear()
gl.render(drawScene, drawCamera)
gl.setRenderTarget(originalTarget)
const temp = renderTargets.current
renderTargets.current = renderTargets.previous
renderTargets.previous = temp
// 通過 onTextureUpdate回調,將當前 FBO 的紋理傳遞給外部
onTextureUpdate(currentTarget.texture)
})
// 組件本身不渲染任何可見元素,僅負責後台處理繪製紋理
return null
}
💡 幀緩衝對象 FBO 與雙緩衝機制
FBO作用:FBO是GPU上的離屏渲染目標,用於存儲中間繪製結果,避免直接渲染到屏幕,提高效率;- 雙緩衝設計:創建兩個
FBO(fboA和fboB),通過renderTargets管理當前幀current和上一幀previous - 每幀將上一幀的
FBO紋理作為輸入,繪製新內容到當前FBO,然後交換兩者的角色,實現繪製效果的熱力圖的漸隱效果。
⑤ 創建渲染場景組件 Scene
Scene 組件是整個交互可視化效果的核心統籌組件,它主要實現的功能包括:整合鼠標交互、參數控制、繪製渲染DrawRenderer 與熱力圖渲染 HeatMesh,實現鼠標 hover 或者 移動時生成動態熱力圖。最終實現的效果是:用户在畫布上移動鼠標,鼠標軌跡會實時生成帶有熱力漸變的動態效果,且效果可通過 Leva 面板參數可以實時調整。
import { DrawRenderer } from "./DrawRenderer"
import { HeatMesh } from "./HeatMesh"
export const Scene = ({ containerRef, }: { containerRef: React.RefObject<HTMLDivElement | null> }) => {
const [mouse, setMouse] = useState<[number, number]>([0, 0])
const [heatAmount, setHeatAmount] = useState(0)
const [drawTexture, setDrawTexture] = useState<Texture | null>(null)
const heatRef = useRef(0)
const lastMousePos = useRef<[number, number]>([0, 0])
const lastTime = useRef(performance.now())
const holdRef = useRef(false)
const { camera, size } = useThree((state) => ({ camera: state.camera, size: state.size }))
// Leva 控制參數增加
const { sizeDamping, fadeDamping, heatSensitivity, heatDecay, radiusSize } = useControls("Hover Heat",{
// 控制粗細的變化平滑度
sizeDamping: { value: 0.8, min: 0.0, max: 1.0, step: 0.01 },
// 控制消失的速度
fadeDamping: { value: 0.98, min: 0.9, max: 1.0, step: 0.001 },
// 鼠標移動時熱度累積的快慢
heatSensitivity: { value: 0.25, min: 0.1, max: 2.0, step: 0.05 },
// 鼠標停止後熱度下降的快慢
heatDecay: { value: 0.92, min: 0.8, max: 0.99, step: 0.01 },
// 控制單次繪製的範圍大小
radiusSize: { value: 75, min: 20, max: 300, step: 5 },
}
)
// 根據畫布尺寸計算相機的寬高比,動態設置 等參數;確保相機的投影矩陣實時更新
useEffect(() => {
if (camera && camera instanceof OrthographicCamera) {
const aspect = size.width / size.height
let width, height
if (aspect >= 1) {
height = 1
width = aspect
} else {
width = 1
height = 1 / aspect
}
camera.left = -width / 2
camera.right = width / 2
camera.top = height / 2
camera.bottom = -height / 2
camera.near = -1
camera.far = 1
camera.updateProjectionMatrix()
}
}, [camera, size])
// 通過pointermove/pointerleave事件監聽鼠標在容器內的位置,計算鼠標相對於容器的歸一化座標
const handleDOMPointerMove = useCallback(
(e: PointerEvent) => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect()
const clientX = e.clientX - rect.x
const clientY = e.clientY - rect.y
const normalizedX = clientX / rect.width
const normalizedY = clientY / rect.height
const x = 2 * (normalizedX - 0.5)
const y = 2 * -(normalizedY - 0.5)
holdRef.current = true
setMouse([x, y])
lastMousePos.current = [x, y]
lastTime.current = performance.now()
}
},
[containerRef]
)
const handleDOMPointerLeave = useCallback(() => {
holdRef.current = false
}, [])
// 鼠標事件監聽
useEffect(() => {
const canvas = containerRef.current
if (!canvas) return
canvas.addEventListener("pointermove", handleDOMPointerMove)
canvas.addEventListener("pointerleave", handleDOMPointerLeave)
return () => {
canvas.removeEventListener("pointermove", handleDOMPointerMove)
canvas.removeEventListener("pointerleave", handleDOMPointerLeave)
}
}, [handleDOMPointerMove, handleDOMPointerLeave, containerRef])
useFrame((_, delta) => {
// 熱度累積:當鼠標在容器內移動holdRef.current = true時,根據heatSensitivity和幀間隔delta計算熱度增量,heatRef.current持續累積最大限制為1.3,避免強度溢出
if (holdRef.current) {
const heatIncrease = heatSensitivity * delta * 60
heatRef.current += heatIncrease
heatRef.current = Math.min(1.3, heatRef.current)
setHeatAmount(heatRef.current)
// 熱度衰減:當鼠標離開容器pointerleave或停止移動時,熱度值按 heatDecay衰減係數逐步降低,直到低於0.001時清零;
} else if (heatRef.current > 0) {
heatRef.current *= heatDecay
heatRef.current = heatRef.current < 0.001 ? 0 : heatRef.current
setHeatAmount(heatRef.current)
}
// 延遲重置:鼠標停止移動後,通過50ms延遲將 holdRef設為false,避免因短暫停頓導致熱度突然中斷,模擬自然殘留感
if (holdRef.current) {
setTimeout(() => {
holdRef.current = false
}, 50)
}
})
const direction = useMemo<[number, number, number, number]>(() => {
return [0, 0, 0, 100]
}, [])
const drawPosition = useMemo<[number, number]>(() => {
const x = 0.5 * mouse[0] + 0.5
const y = 0.5 * mouse[1] + 0.5
return [x, y]
}, [mouse])
// 向 DrawRenderer 傳遞繪製數據,接收繪製結果並傳遞給 HeatMesh
return (
<>
<DrawRenderer
size={256}
position={drawPosition}
direction={direction}
drawAmount={heatAmount}
onTextureUpdate={setDrawTexture}
sizeDamping={sizeDamping}
fadeDamping={fadeDamping}
radiusSize={radiusSize}
/>
<HeatMesh drawTexture={drawTexture} />
</>
)
}
通過 Leva 控制面板動態調節着色器參數。
⑥ 自定義顏色功能實現
可以通過如下的方法,生成隨機色彩並將生成的參數傳遞到着色器,可以實現熱力圖 logo 顏色的動態切換。
const randomizeColors = useCallback(() => {
const hslToHex = (h: number, s: number, l: number) => {
s /= 100
l /= 100
const k = (n: number) => (n + h / 30) % 12
const a = s * Math.min(l, 1 - l)
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
const toHex = (x: number) => Math.round(255 * x).toString(16).padStart(2, "0")
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`
}
// 生成6種隨機顏色 color2..color7,color1保持黑色
const base = Math.floor(Math.random() * 360)
const steps = [15, 35, 55, 85, 140, 200]
const palette = steps.map((step, i) => hslToHex((base + step) % 360, 80 - i * 4, 50 + (i - 3) * 3))
const keys = ["color2", "color3", "color4", "color5", "color6", "color7"] as const
keys.forEach((key, i) => {
levaStore.setValueAtPath(`Heat Map.${key}`, palette[i], false)
})
}, [])
⑦ 着色器
📦 draw.frag
precision highp float;
uniform float uDraw;
uniform vec3 uRadius;
uniform vec3 uResolution;
uniform vec2 uPosition;
uniform vec4 uDirection;
uniform float uSizeDamping;
uniform float uFadeDamping;
uniform sampler2D uTexture;
varying vec2 vUv;
void main() {
float aspect = uResolution.x / uResolution.y;
vec2 pos = uPosition;
pos.y /= aspect;
vec2 uv = vUv;
uv.y /= aspect;
float dist = distance(pos, uv) / (uRadius.z / uResolution.x);
dist = smoothstep(uRadius.x, uRadius.y, dist);
vec3 dir = uDirection.xyz * uDirection.w;
vec2 offset = vec2((-dir.x) * (1.0-dist), (dir.y) * (1.0-dist));
vec2 uvt = vUv;
vec4 color = texture2D(uTexture, uvt + (offset * 0.01));
color *= uFadeDamping;
color.r += offset.x;
color.g += offset.y;
color.rg = clamp(color.rg, -1.0, 1.0);
float d = uDraw;
color.b += d * (1.0-dist);
gl_FragColor = vec4(color.rgb, 1.0);
}
📦 heat.frag
precision highp isampler2D;
precision highp usampler2D;
uniform sampler2D drawMap;
uniform sampler2D textureMap;
uniform sampler2D maskMap;
uniform float amount;
uniform float opacity;
uniform vec3 color1;
uniform vec3 color2;
uniform vec3 color3;
uniform vec3 color4;
uniform vec3 color5;
uniform vec3 color6;
uniform vec3 color7;
uniform vec4 blend;
uniform vec4 fade;
uniform vec4 maxBlend;
uniform float power;
varying vec2 vUv;
varying vec4 vClipPosition;
vec3 linearRgbToLuminance(vec3 linearRgb){
float finalColor = dot(linearRgb, vec3(0.2126729, 0.7151522, 0.0721750));
return vec3(finalColor);
}
vec3 saturation(vec3 color, float saturation){
return mix(linearRgbToLuminance(color), color, saturation);
}
vec3 gradient(float t) {
float p1 = blend.x;
float p2 = blend.y;
float p3 = blend.z;
float p4 = blend.w;
float p5 = maxBlend.x;
float p6 = maxBlend.y;
float f1 = fade.x;
float f2 = fade.y;
float f3 = fade.z;
float f4 = fade.w;
float f5 = maxBlend.z;
float f6 = maxBlend.w;
float blend1 = smoothstep(p1 - f1 * 0.5, p1 + f1 * 0.5, t);
float blend2 = smoothstep(p2 - f2 * 0.5, p2 + f2 * 0.5, t);
float blend3 = smoothstep(p3 - f3 * 0.5, p3 + f3 * 0.5, t);
float blend4 = smoothstep(p4 - f4 * 0.5, p4 + f4 * 0.5, t);
float blend5 = smoothstep(p5 - f5 * 0.5, p5 + f5 * 0.5, t);
float blend6 = smoothstep(p6 - f6 * 0.5, p6 + f6 * 0.5, t);
vec3 color = color1;
color = mix(color, color2, blend1);
color = mix(color, color3, blend2);
color = mix(color, color4, blend3);
color = mix(color, color5, blend4);
color = mix(color, color6, blend5);
color = mix(color, color7, blend6);
return color;
}
void main() {
vec2 duv = vClipPosition.xy/vClipPosition.w;
duv = 0.5 + duv * 0.5;
vec2 uv = vUv;
uv -= 0.5;
uv += 0.5;
float o = clamp(opacity, 0.0, 1.0);
float a = clamp(amount, 0.0, 1.0);
float v = o * a;
vec4 tex = texture2D(maskMap, uv);
float mask = tex.g;
float logo = smoothstep(0.58, 0.6, 1.0-tex.b);
vec2 wuv = uv;
vec3 draw = texture2D(drawMap, duv).rgb;
float heatDraw = draw.b;
heatDraw *= mix(0.1, 1.0, mask);
vec2 offset2 = draw.rg * 0.01;
vec3 video = textureLod(textureMap, wuv + offset2, 0.0).rgb;
float h = mix(pow(1.0-video.r, 1.5), 1.0, 0.2) * 1.25;
heatDraw *= h;
float map = video.r;
map = pow(map, power);
float msk = smoothstep(0.2, 0.5, uv.y);
map = mix( map * 0.91, map, msk);
map = mix(0.0, map, v);
float fade2 = distance(vUv, vec2(0.5, 0.52));
fade2 = smoothstep(0.5, 0.62, 1.0-fade2);
vec3 finalColor = gradient(map + heatDraw);
finalColor = saturation(finalColor, 1.3);
finalColor *= fade2;
finalColor = mix(vec3(0.0), finalColor, a);
gl_FragColor = vec4(finalColor, 1.0);
}
總結
📌 本項目代碼主要由 4 個核心組件構成,其中:
- HeatmapScene:是全局容器,作為頂層組件,管理
Three.js、Leva 控制面板和其他頁面信息;通過levaStore全局管理熱力圖顏色參數,傳遞容器引用給子組件。 - Scene:交互與統籌,處理鼠標交互,計算熱度值,模擬鼠標軌跡的累積與衰減,串聯
DrawRenderer和HeatMesh,傳遞交互參數。 - DrawRenderer:繪製處理,使用雙幀緩衝
FBO實現離屏繪製,高效累積鼠標軌跡,通過自定義着色器處理軌跡的繪製、漸隱與衰減,輸出處理後的繪製紋理drawTexture給HeatMesh。 - HeatMesh:熱力圖渲染,基於
DrawRenderer輸出的紋理,結合視頻紋理和遮罩紋理,通過自定義着色器生成熱力圖效果。
📌 本文中主要包含的新知識點如下:
Three.js離屏渲染技術FBO:通過useFBO創建幀緩衝對象,實現GPU層面的離屏繪製,避免直接操作DOM提升性能;雙緩衝機制fboA/fboB交替渲染實現繪製軌跡的累積與動態更新。交互事件與動態參數控制:鼠標鍵盤事件監聽:將用户輸入轉換為可量化的參數。Leva控制面板:通過useControls實時調整視覺參數,提升開發靈活性。- 視頻紋理、遮罩紋理、着色器材質的使用等。
想了解其他前端知識或其他未在本文中詳細描述的Web 3D開發技術相關知識,可閲讀我往期的文章。如果有疑問可以在評論中留言,如果覺得文章對你有幫助,不要忘了一鍵三連哦 👍。
附錄
- [1]. 🌴 Three.js 打造繽紛夏日3D夢中情島
- [2]. 🔥 Three.js 實現炫酷的賽博朋克風格3D數字地球大屏
- [3]. 🐼 Three.js 實現2022冬奧主題3D趣味頁面,含冰墩墩
- [4]. 🦊 Three.js 實現3D開放世界小遊戲:阿狸的多元宇宙
- [5]. 🏡 Three.js 進階之旅:全景漫遊-高階版在線看房
...- 【Three.js 進階之旅】系列專欄訪問 👈
- 更多往期【3D】專欄訪問 👈
- 更多往期【前端】專欄訪問 👈