紋理融合——用 TypeScript + Babylon.js 打造“可混合紋理序列”

????????我不想搞個一新的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…