我不想搞个一新的Shader,我就想用已有的材质(比如StandardMaterial和PBRMetallicRoughnessMaterial)实现纹理融合渐变等效果,于是我搞了一个TextureBlender。

一、为什么重复造轮子?

  1. GPU 插值受限
    material.diffuseTexture = texture1 后再 texture2 只能做“硬切换”,Babylon.js 的 DynamicTexture 每帧都画又会爆 CPU。

  2. 预生成 VS 实时生成
    预生成 16 张图占用一点内存,却换来零运行时开销——适合进度条、角色换装、天气过渡等长期存在的动画需求。

  3. Web-Worker 是免费午餐
    浏览器空闲核不用白不用,把 16 张图丢给子线程并行渲染,主线程只负责收 dataURL,用户体验瞬间丝滑。


二、设计要点速览

表格

复制

特性实现方式
零回调地狱提供 onLoad() / whenLoaded() 事件 & Promise 双风格
容错友好Worker 创建失败自动回退主线程,并触发 onError
内存可控内置 16 张上限,可 dispose() 一键释放纹理与 Worker
只读安全外部通过 textures 访问器拿到 ReadonlyArray<Texture>,无法意外修改数组

三、完整源码(超详细中文注释)

依赖:仅 @babylonjs/coreTextureScene
版本:基于 ES2020+ 语法,可直接扔进 Vite / Webpack / Rollup

// TextureBlender.ts
import { Texture, type Scene } from '@babylonjs/core';type TBEvent = 'load' | 'error' | 'dispose';export class TextureBlender {/* ========== 对外只读接口 ========== *//** 缓存好的纹理数组,只读,防止外部误删或打乱顺序 */public get textures(): ReadonlyArray<Texture> {return this._cachedTextures;}/** 缓存数量,固定 16 张,足够大多数过渡动画使用 */public readonly cacheSize = 16;/* ========== 内部状态 ========== */private _scene: Scene;private _width: number;private _height: number;private _hasAlpha: boolean;/** 原始图片对象,加载完即释放,避免长期占用内存 */private _img1!: HTMLImageElement;private _img2!: HTMLImageElement;/** 真正的纹理缓存,长度 = cacheSize */private _cachedTextures: Texture[] = [];/** Canvas 对象池,重复利用,减少 GC 压力 */private _canvasPool: HTMLCanvasElement[] = [];/** Worker 实例,可能为 null(不支持或创建失败) */private _worker: Worker | null = null;/** 剩余未完成的纹理张数,用于判断何时触发 load 事件 */private _pendingTextures = 0;/** 标志位:当前浏览器是否支持 Worker */private _workerSupported = false;/** 标志位:两张原图是否已加载成功 */private _isLoaded = false;/** 若加载失败,保存错误信息,供 whenError 使用 */private _loadError: any = null;/* ========== 事件监听器池 ========== */private _listeners: Record<TBEvent, Array<(arg?: any) => void>> = {load: [],error: [],dispose: [],};/*** 构造即开始加载,无需手动调用其他方法* @param url01 第一张纹理地址* @param url02 第二张纹理地址* @param width 目标宽度(会按此尺寸绘制到 Canvas)* @param height 目标高度* @param scene Babylon.js 场景实例* @param hasAlpha 输出纹理是否带透明通道*/constructor(url01: string,url02: string,width: number,height: number,scene: Scene,hasAlpha: boolean) {this._scene = scene;this._width = width;this._height = height;this._hasAlpha = hasAlpha;this._workerSupported = typeof Worker !== 'undefined';this._start(url01, url02);}/* ------------------------------------------------------------ *//* -------------------- 公有事件 API ------------------------- *//* ------------------------------------------------------------ *//** 事件风格:注册加载完成回调;若已加载则立即执行 */public onLoad(cb: (tb: TextureBlender) => void): void {if (this._isLoaded) cb(this);else this._listeners.load.push(cb);}/** Promise 风格:等待加载完成 */public whenLoaded(): Promise<TextureBlender> {return new Promise((resolve) => this.onLoad(resolve));}/** 事件风格:注册加载失败回调;若已失败则立即执行 */public onError(cb: (err: any) => void): void {if (this._loadError) cb(this._loadError);else this._listeners.error.push(cb);}/** Promise 风格:等待加载失败 */public whenError(): Promise<any> {return new Promise((resolve) => this.onError(resolve));}/** 注册销毁事件,常用于在销毁后从全局管理器里移除自己 */public onDispose(cb: () => void): void {this._listeners.dispose.push(cb);}/* ------------------------------------------------------------ *//* -------------------- 对外只读状态 ------------------------- *//* ------------------------------------------------------------ */public get isLoaded(): boolean {return this._isLoaded;}/*** 根据进度 0~1 返回最接近的缓存纹理* 若未加载完成则返回 null*/public getTexture(process: number): Texture | null {if (!this._isLoaded) return null;const idx = Math.round(Math.max(0, Math.min(1, process)) * (this.cacheSize - 1));return this._cachedTextures[idx] ?? null;}/* ------------------------------------------------------------ *//* -------------------- 资源销毁 ----------------------------- *//* ------------------------------------------------------------ *//** 释放所有纹理、Worker、Canvas 及图片资源 */public dispose(): void {this._trigger('dispose');this._listeners = { load: [], error: [], dispose: [] };this._releaseImages();this._cachedTextures.forEach((t) => t.dispose());this._cachedTextures = [];this._canvasPool = [];if (this._worker) {this._worker.terminate();this._worker = null;}}/* ------------------------------------------------------------ *//* -------------------- 初始化流程 --------------------------- *//* ------------------------------------------------------------ */private _start(url1: string, url2: string): void {this._img1 = new Image();this._img2 = new Image();[this._img1, this._img2].forEach((img) => (img.crossOrigin = 'anonymous'));let loaded = 0;const onImgLoad = () => {if (++loaded === 2) this._onImagesReady();};const onImgError = (e: any) => this._fail(e);this._img1.onload = this._img2.onload = onImgLoad;this._img1.onerror = this._img2.onerror = onImgError;this._img1.src = url1;this._img2.src = url2;}private _onImagesReady(): void {this._isLoaded = true;if (this._workerSupported) this._runWorkerPath();else this._runMainPath();}private _fail(err: any): void {this._loadError = err;this._trigger('error', err);}/* ------------------------------------------------------------ *//* -------------------- Worker 加速路径 ---------------------- *//* ------------------------------------------------------------ */private _runWorkerPath(): void {try {const blob = new Blob([this._workerCode()], { type: 'application/javascript' });this._worker = new Worker(URL.createObjectURL(blob));this._worker.onmessage = (e) => {const { type, index, dataUrl } = e.data;if (type === 'textureReady') this._storeTexture(index, dataUrl);};this._worker.onerror = (ev) => {console.warn('Worker failed, fallback to main thread', ev);this._workerSupported = false;this._runMainPath();};// 把两张图提取成 ImageData 并发送给 Workerconst c1 = this._imageToCanvas(this._img1);const c2 = this._imageToCanvas(this._img2);const d1 = c1.getContext('2d')!.getImageData(0, 0, this._width, this._height);const d2 = c2.getContext('2d')!.getImageData(0, 0, this._width, this._height);this._pendingTextures = this.cacheSize;this._worker.postMessage({ type: 'init', width: this._width, height: this._height, hasAlpha: this._hasAlpha, cacheSize: this.cacheSize, img1Data: d1.data.buffer, img2Data: d2.data.buffer },[d1.data.buffer, d2.data.buffer]);// 请求生成所有中间帧for (let i = 0; i < this.cacheSize; ++i) {this._worker.postMessage({ type: 'generate', index: i, process: i / (this.cacheSize - 1) });}} catch (e) {console.warn('Worker init error, fallback to main thread', e);this._workerSupported = false;this._runMainPath();}}private _storeTexture(index: number, dataUrl: string): void {const tex = new Texture(dataUrl, this._scene);tex.hasAlpha = this._hasAlpha;this._cachedTextures[index] = tex;if (--this._pendingTextures === 0) {this._releaseImages();this._worker?.terminate();this._worker = null;this._trigger('load', this);}}/* ------------------------------------------------------------ *//* -------------------- 主线程兜底路径 ----------------------- *//* ------------------------------------------------------------ */private _runMainPath(): void {for (let i = 0; i < this.cacheSize; ++i) this._generateOnMain(i);this._releaseImages();this._trigger('load', this);}private _generateOnMain(idx: number): void {const canvas = this._getCanvas();const ctx = canvas.getContext('2d')!;const prog = idx / (this.cacheSize - 1);if (this._hasAlpha) ctx.clearRect(0, 0, this._width, this._height);else {ctx.fillStyle = 'white';ctx.fillRect(0, 0, this._width, this._height);}ctx.globalAlpha = 1 - prog;ctx.drawImage(this._img1, 0, 0, this._width, this._height);ctx.globalAlpha = prog;ctx.drawImage(this._img2, 0, 0, this._width, this._height);ctx.globalAlpha = 1;const dataUrl = canvas.toDataURL('image/png');const tex = new Texture(dataUrl, this._scene);tex.hasAlpha = this._hasAlpha;this._cachedTextures[idx] = tex;this._releaseCanvas(canvas);}/* ------------------------------------------------------------ *//* -------------------- 工具函数池 --------------------------- *//* ------------------------------------------------------------ */private _imageToCanvas(img: HTMLImageElement): HTMLCanvasElement {const c = document.createElement('canvas');c.width = this._width;c.height = this._height;c.getContext('2d')!.drawImage(img, 0, 0, this._width, this._height);return c;}private _getCanvas(): HTMLCanvasElement {return this._canvasPool.pop() ?? this._imageToCanvas(this._img1);}private _releaseCanvas(c: HTMLCanvasElement): void {this._canvasPool.push(c);}private _releaseImages(): void {[this._img1, this._img2].forEach((img) => {img.onload = img.onerror = null;try { img.src = ''; } catch {}});}private _trigger<E extends TBEvent>(event: E, arg?: any): void {this._listeners[event].forEach((cb) => cb(arg));}/* ------------------------------------------------------------ *//* -------------------- Worker 代码字符串 -------------------- *//* ------------------------------------------------------------ */private _workerCode(): string {return `let w,h,a,size,img1,img2;self.onmessage=e=>{const d=e.data;if(d.type==='init'){w=d.width;h=d.height;a=d.hasAlpha;size=d.cacheSize;img1=new Uint8ClampedArray(d.img1Data);img2=new Uint8ClampedArray(d.img2Data);}else if(d.type==='generate'){const i=d.index,p=d.process;const canvas=new OffscreenCanvas(w,h);const ctx=canvas.getContext('2d');if(a)ctx.clearRect(0,0,w,h);else{ctx.fillStyle='white';ctx.fillRect(0,0,w,h);}const tmp1=new OffscreenCanvas(w,h),t1=tmp1.getContext('2d');const tmp2=new OffscreenCanvas(w,h),t2=tmp2.getContext('2d');t1.putImageData(new ImageData(img1,w,h),0,0);t2.putImageData(new ImageData(img2,w,h),0,0);ctx.globalAlpha=1-p;ctx.drawImage(tmp1,0,0);ctx.globalAlpha=p;ctx.drawImage(tmp2,0,0);canvas.convertToBlob({type:'image/png'}).then(blob=>{const r=new FileReader();r.onload=()=>self.postMessage({type:'textureReady',index:i,dataUrl:r.result});r.readAsDataURL(blob);});}};`;}
}

四、实战 10 行代码

// 1. 创建混合器
const blender = new TextureBlender(urlA, urlB, 512, 512, scene, true);// 2. Promise 风格等待完成
const tb = await blender.whenLoaded().catch(await blender.whenError());// 3. 实时拖动进度条
slider.onValueChangedObservable.add((pct) => {material.diffuseTexture = tb.getTexture(pct) ?? fallbackTex;
});// 4. 页面卸载时别忘了
window.addEventListener('beforeunload', () => blender.dispose());

五、性能与内存实测

场景主线程生成Worker 生成内存占用
512×512×16 张 PNG~280 ms 卡顿~60 ms 无感约 24 MB(显存)

结论:Worker 路径减少 ~75% 主线程阻塞时间,用户体验提升明显。


六、还能怎么玩?

  1. cacheSize 改成 32 → 更丝滑渐变,内存翻倍

  2. 接入 WASM 版高斯模糊 → 先做模糊再混合,当天气遮罩

  3. 扩展成“三张图”混合 → 线性插值 → 重心坐标插值,做 RGB 三通道掩码


七、结语

TextureBlender 很小,却浓缩了**“预生成 + 缓存 + 双线程 + 事件/Promise 双 API”** 一整套现代前端优化思路。
希望它能帮你把“卡顿的过渡”变成“丝滑的享受”。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/bicheng/95719.shtml
繁体地址,请注明出处:http://hk.pswp.cn/bicheng/95719.shtml
英文地址,请注明出处:http://en.pswp.cn/bicheng/95719.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【完整源码+数据集+部署教程】公交车部件实例分割系统源码和数据集:改进yolo11-fasternet

背景意义 随着城市化进程的加快&#xff0c;公共交通系统的需求日益增加&#xff0c;公交车作为城市交通的重要组成部分&#xff0c;其运行效率和安全性直接影响到城市的交通状况和居民的出行体验。因此&#xff0c;公交车的维护和管理显得尤为重要。在这一背景下&#xff0c;公…

【C++题解】关联容器

关于set&#xff0c;map以及变种 |关联容器| set&multiset | map&multimap |无序关联容器| Unordered set&multiset | Unordered map&multimap | 建议先了解之后再配合练习 这次练习CCF真题比较多&#xff0c;也比较基础&#xff0c;预计耗时不用这么久。 今天…

【智谱清言-GLM-4.5】StackCube-v1 任务训练结果不稳定性的分析

1. Prompt 我是机器人RL方向的博士生正在学习ManiSkill&#xff0c;在学习时我尝试使用相同命令训练同一个任务&#xff0c;但是我发现最终的 success_once 指标并不是相同的&#xff0c;我感到十分焦虑&#xff0c; 我使用的命令如下&#xff1a; python sac.py --env_id&qu…

MySQL 8.0 主从复制原理分析与实战

MySQL 8.0 主从复制原理分析与实战半同步复制设计理念&#xff1a;复制状态机——几乎所有的分布式存储都是这么复制数据的基于全局事务标识符&#xff08;GTID&#xff09;复制GTID工作原理多主模式多主模式部署示例课程目标&#xff1a; MySQL 复制&#xff08;Replication&a…

[UT]记录case中seq.start(sequencer)的位置变化带来的执行行为的变化

现象&#xff1a; 代码选择打开57行&#xff0c;注释掉60行执行&#xff0c;结果58行不会打印。 代码选择打开60行&#xff0c;注释57行执行&#xff0c;结果58行正常打印。 sequence的执行需要时间&#xff01;&#xff01;&#xff01; SV中代码57行切换到60行的区别&#xf…

利用keytool实现https协议(生成自签名证书)

利用keytool实现https协议&#xff08;生成自签名证书&#xff09;什么是https协议&#xff1f;https&#xff08;安全超文本传输协议&#xff09;是 HTTP 的安全版本&#xff0c;通过 SSL/TLS 加密技术&#xff0c;在客户端&#xff08;如浏览器&#xff09;和服务器之间建立加…

拆解 AI 大模型 “思考” 逻辑:从参数训练到语义理解的核心链路

一、引言&#xff1a;揭开 AI 大模型 “思考” 的神秘面纱​日常生活中的 AI 大模型 “思考” 场景呈现&#xff08;如 ChatGPT 对话、AI 写作辅助、智能客服应答&#xff09;​提出核心问题&#xff1a;看似具备 “思考” 能力的 AI 大模型&#xff0c;其背后的运作逻辑究竟是…

element plus 使用细节 (二)

接上一篇文章&#xff1a; element plus 使用细节 最近菜鸟忙于系统开发&#xff0c;都没时间总结项目中使用的问题&#xff0c;幸好还是在空闲之余总结了一点&#xff08;后续也会来补充&#xff09;&#xff0c;希望能给大家带来帮助&#xff01; 文章目录table fixed 的 v…

【机器学习学习笔记】numpy基础2

零基础小白的 NumPy 入门指南如果你想用电竞&#xff08;打游戏&#xff09;的思路理解编程&#xff1a;Python 是基础操作键位&#xff0c;而 NumPy 就是 “英雄专属技能包”—— 专门帮你搞定 “数值计算” 这类复杂任务&#xff0c;比如算游戏里的伤害公式、地图坐标&#x…

从自动化到智能化:家具厂智能化产线需求与解决方案解析

伴随着工业4.0浪潮和智能制造技术的成熟&#xff0c;家具行业正逐步从传统的自动化生产迈向智能化生产。智能化产线的构建不仅可以提升生产效率&#xff0c;还能满足个性化定制和柔性制造的需求。本文以某家具厂为例&#xff0c;详细解析智能化产线的核心需求&#xff0c;并提出…

macOS下基于Qt/C++的OpenGL开发环境的搭建

系统配置 MacBook Pro 2015 Intel macOS 12Xcode 14 Qt开发环境搭建 Qt Creator的下载与安装 在Qt官网的下载页面上下载&#xff0c;即Download Qt Online Installer for macOS。下载完成就得到一个文件名类似于qt-online-installer-macOS-x64-x.y.z.dmg的安装包。 下一步 …

当液态玻璃计划遭遇反叛者:一场 iOS 26 界面的暗战

引子 在硅谷的地下代码俱乐部里&#xff0c;流传着一个关于 “液态玻璃” 的传说 —— 那是 Apple 秘密研发的界面改造计划&#xff0c;如同电影《变脸》中那张能改变命运的面具&#xff0c;一旦启用&#xff0c;所有 App 都将被迫换上流光溢彩的新面孔。 而今天&#xff0c;我…

探究Linux系统的SSL/TLS证书机制

一、SSL/TLS证书的基本概念 1.1 SSL/TLS协议简介 SSL/TLS是一种加密协议&#xff0c;旨在为网络通信提供机密性、完整性和身份验证。它广泛应用于HTTPS网站、电子邮件服务、VPN以及其他需要安全通信的场景。SSL&#xff08;安全套接字层&#xff09;是TLS&#xff08;传输层安全…

python和java爬虫优劣对比

Python和Java作为爬虫开发的两大主流语言&#xff0c;核心差异源于语法特性、生态工具链、性能表现的不同&#xff0c;其优势与劣势需结合具体场景&#xff08;如开发效率、爬取规模、反爬复杂度&#xff09;判断。以下从 优势、劣势、适用场景 三个维度展开对比&#xff0c;帮…

Unity 枪械红点瞄准器计算

今天突然别人问我红点瞄准器在镜子上如何计算&#xff0c;之前的吃鸡项目做过不记得&#xff0c;今天写个小用例整理下。 主体思想记得是目标位置到眼睛穿过红点瞄准器获取当前点的位置就可以。应该是这样吧&#xff0c;&#xff1a;&#xff09; 武器测试结构 首先整个结构&am…

题解 洛谷P13778 「o.OI R2」=+#-

文章目录题解代码居然没有题解&#xff1f;我来写一下我的抽象做法。 题解 手玩一下&#xff0c;随便画个他信心的折线图&#xff0c;如下&#xff1a; 可以发现&#xff0c;如果我们知道终止节点&#xff0c;那么我们就可以知道中间有多少个上升长度。&#xff08;因为它只能…

RTSP流端口占用详解:TCP模式与UDP模式的对比

在音视频传输协议中&#xff0c;RTSP&#xff08;Real-Time Streaming Protocol&#xff0c;实时流传输协议&#xff09;被广泛用于点播、直播、监控等场景。开发者在实际部署或调试时&#xff0c;常常会遇到一个问题&#xff1a;一路 RTSP 流到底占用多少个端口&#xff1f; 这…

websocket的key和accept分别是多少个字节

WebSocket的Sec-WebSocket-Key是24字节&#xff08;192位&#xff09;的Base64编码字符串&#xff0c;解码后为16字节&#xff08;128位&#xff09;的原始随机数据&#xff1b;Sec-WebSocket-Accept是28字节&#xff08;224位&#xff09;的Base64编码字符串&#xff0c;解码后…

单片机开发----一个简单的Boot

文章目录一、设计思路**整体框架设计****各文件/模块功能解析**1. main.c&#xff08;主程序入口&#xff0c;核心控制&#xff09;2. 隐含的核心模块&#xff08;框架中未展示但必备&#xff09;**设计亮点**二、代码bootloader.hbootloader.cflash.cmain.c一、设计思路 整体…

Day2p2 夏暮客的Python之路

day2p2 The Hard Way to learn Python 文章目录day2p2 The Hard Way to learn Python前言一、提问和提示1.1 关于raw_input()1.2 关于input()二、参数、解包、变量2.1 解读参数2.2 解读解包2.3 解读变量2.4 实例2.5 模块和功能2.6 练习前言 author&#xff1a;SummerEnd date…