C#实现屏幕墙:同时监控多个电脑桌面(支持Windows、信创Linux、银河麒麟、统信UOS)

蓖幌谮热Apple 2025 年度发布会 LOGO 以标志性的苹果图形被注入炽热的火焰质感,色彩从暖调橙黄向冷调湛蓝自然过渡,似高温灼烧下的金属表面,迸发出熔融的光泽;又若无形的能量在流动,勾勒出科技的脉搏与律动,将 “科技” 与 “力量” 的碰撞感具象化,光影的明暗交错削弱了平面的单薄感,赋予其近乎触手可及的质感,同时营造出浓郁的未来感与未知感。

摘要

如上述引用内容,本文将基于 React + Three.js + GLSL 的相关知识,实现 Apple 2025 动态热成像 logo 效果。通过本文的阅读和学习,你将学习到的知识点包括:离屏渲染技术 FBO、交互事件与动态参数控制、Leva 控制面板的应用、视频纹理、遮罩纹理、着色器材质的使用、热成像动画着色器实现和应用等。

效果

本文页面实现效果如下图所示,页面页面中心由 Apple 热成像动态图标构成,图标上面由橙色和蓝色渐变色动态流动,页面底部为蓝色渐变文案。

preview

当使用鼠标 ??? 或触控板 ?? 网页上按压或拖动 Logo 时,可以看到颜色随手势展开变化,看起来像是模拟真实热量轨迹。

ctrl

本专栏系列代码托管在 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 (

setLevaHidden((p) => !p)} onRandomizeColors={randomizeColors} />

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

>

@dragonir

)

③ 实现动态热力图网格 HeatMesh

HeatMesh 组件,它主要通过视频纹理 VideoTexture、绘制纹理 drawTexture 和遮罩纹理 maskTexture 作为数据源,使用着色器材质 ShaderMaterial 渲染一个平面网格 planeGeometr,实现了可实时调整的热力图效果。ShaderMaterial 通过传入自定义顶点着色器和片元着色器实现复杂的热力图色彩映射和动态效果。

export const HeatMesh = ({ drawTexture }: { drawTexture: Texture | null }) => {

const timeRef = useRef(0)

const videoRef = useRef(null)

const [videoTexture, setVideoTexture] = useState(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 (

)

}

其中 heatVertexShader 和 heatFragmentShader 是着色器材质的顶点着色器和片元着色器,它们的详细内容见文章最后的着色器模块。

顶点着色器 heatVertexShader:处理网格顶点的位置变换

片元着色器 heatFragmentShader:根据输入纹理的像素值,结合颜色映射参数,计算每个像素的最终颜色,实现热力图效果。

遮罩纹理图片预览

mask

④ 实现绘制渲染器组件 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 }) => {

const [mouse, setMouse] = useState<[number, number]>([0, 0])

const [heatAmount, setHeatAmount] = useState(0)

const [drawTexture, setDrawTexture] = useState(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 (

<>

size={256}

position={drawPosition}

direction={direction}

drawAmount={heatAmount}

onTextureUpdate={setDrawTexture}

sizeDamping={sizeDamping}

fadeDamping={fadeDamping}

radiusSize={radiusSize}

/>

</>

)

}

通过 Leva 控制面板动态调节着色器参数。

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)

})

}, [])

color1

⑦ 着色器

?? 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);

}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值