@avatar-platform/avatar-render 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -2
- package/dist/config.d.ts +19 -0
- package/dist/config.js +30 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +314 -0
- package/package.json +27 -4
package/README.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Avatar Render
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@avatar-platform/avatar-render` 是独立的浏览器端 TypeScript / ESM npm 包,用于完整 GLB 模型预览和 HDR / EXR 摄影棚环境照明。不依赖 Vue、React、仓库内部包、后端或业务 API。导入模块不会创建 WebGL;在浏览器挂载后创建实例。
|
|
4
|
+
|
|
5
|
+
## 构建与安装
|
|
6
|
+
|
|
7
|
+
在仓库根目录运行:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm --filter @avatar-platform/avatar-render build
|
|
11
|
+
pnpm --filter @avatar-platform/avatar-render test
|
|
12
|
+
pnpm --filter @avatar-platform/avatar-render pack --pack-destination ./tmp/avatar-render
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
生成的 `.tgz` 可在其他项目通过 `npm install /path/to/avatar-platform-avatar-render-0.1.0.tgz three@^0.180.0` 安装。本包未发布到 npm registry;正式发布前由维护者确认包名、访问级别和许可证。`three` 为 peer dependency,不重复打包;仅提供 ESM,TypeScript 声明随包分发。包的 tsconfig 独立于仓库,可复制整个目录单独开发。
|
|
16
|
+
|
|
17
|
+
## 最小接入
|
|
18
|
+
|
|
19
|
+
容器必须有实际宽高,例如 `width: 100%; height: 600px`。
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { AvatarRenderer } from '@avatar-platform/avatar-render';
|
|
23
|
+
|
|
24
|
+
const viewer = new AvatarRenderer({
|
|
25
|
+
container: document.querySelector<HTMLElement>('#viewer')!,
|
|
26
|
+
config: { intensity: 1, exposure: 1, rotation: 35 }
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 模型和环境独立加载;任何失败均通过 Promise reject 返回。
|
|
30
|
+
const results = await Promise.allSettled([
|
|
31
|
+
viewer.loadModel('/assets/avatar.glb'),
|
|
32
|
+
viewer.loadEnvironment('/assets/studio.hdr')
|
|
33
|
+
]);
|
|
34
|
+
for (const result of results) {
|
|
35
|
+
if (result.status === 'rejected') console.error(result.reason);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 将你自己的滑块、颜色选择器、开关绑定到这些参数。
|
|
39
|
+
viewer.setConfig({ exposure: 0.8, rotation: 60, autoRotate: true });
|
|
40
|
+
const config = viewer.getConfig(); // 独立副本,可自行保存成 JSON
|
|
41
|
+
viewer.setConfig(config); // 恢复已保存的配置
|
|
42
|
+
viewer.fit(); // 正面取景,显示完整模型
|
|
43
|
+
viewer.reset(); // 恢复库默认配置并重新取景
|
|
44
|
+
|
|
45
|
+
// 页面/组件卸载时调用。
|
|
46
|
+
viewer.dispose();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Vue 在 `onMounted` 创建,`onBeforeUnmount` 销毁;React 在 effect 创建并在 cleanup 销毁。现有 Web `/render-lab` 是完整 Vue 接入示例,面板属于宿主 UI,包提供全部控制接口。
|
|
50
|
+
|
|
51
|
+
## 模型和环境输入
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
await viewer.loadModel(fileInput.files![0]); // 本地 .glb File
|
|
55
|
+
await viewer.loadEnvironment(hdrInput.files![0]); // 本地 .hdr / .exr File
|
|
56
|
+
await viewer.loadEnvironment('/signed-download?id=123', 'exr');
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- URL 可以是相对路径、同源地址、CORS 允许的远端地址或宿主创建的 blob URL。URL 不要求以 `.glb` 结尾;环境格式按去掉 query/hash 的后缀推断,无后缀时显式传 `hdr` / `exr`。
|
|
60
|
+
- 本地 File 自动创建并回收 object URL,不上传服务器。GLB 应为完整、自包含文件;不支持通过单个 File 寻找外部贴图或 `.gltf` 依赖目录。
|
|
61
|
+
- 模型只在显示层居中、统一尺寸,保留模型原始节点变换、骨骼及 PBR 材质。不拼装/导出模型,不播放动画。Y 轴向上、默认面向 +Z,其他朝向可用 `modelRotation` 调整。
|
|
62
|
+
- 同一类资源连续加载采用最后一次调用优先;旧请求完成后释放资源并抛 `AbortError`,不覆盖新请求。替换失败保留先前有效资源。`dispose()` 后的迟到加载同样释放并抛 `AbortError`。底层请求不保证中途停止下载。
|
|
63
|
+
- Draco / KTX2 压缩模型可通过构造参数 `configureModelLoader(loader, renderer)` 配置 Three.js 解码器;解码器地址、加载及解码器自身销毁由宿主负责。默认 GLTFLoader 不配置远端解码设施。
|
|
64
|
+
- GLB、HDR、EXR、凭据、业务地址均不打包;HDR 必须由调用方传入。PNG 全景预览图不作为 HDR 环境输入。
|
|
65
|
+
|
|
66
|
+
## 配置
|
|
67
|
+
|
|
68
|
+
`config` 和 `setConfig()` 都接受 `Partial<RenderConfig>`。角度单位为度;无效值抛错,整次更新不生效,不静默截断。
|
|
69
|
+
|
|
70
|
+
| 字段 | 默认值 | 支持范围 / 含义 |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| intensity | 1 | 0–3,环境照明与全景背景强度 |
|
|
73
|
+
| exposure | 1 | 0.1–3,色彩映射曝光 |
|
|
74
|
+
| rotation | 35 | -180–180,环境照明和全景同步绕 Y 轴旋转 |
|
|
75
|
+
| modelRotation | 0 | -180–180,模型绕 Y 轴旋转 |
|
|
76
|
+
| background | #24262b | #RRGGBB 背景颜色 |
|
|
77
|
+
| panorama | false | 显示 HDR/EXR 全景;无环境时仍显示背景色 |
|
|
78
|
+
| autoRotate | false | 相机自动环绕模型 |
|
|
79
|
+
| wireframe | false | 线框检查 |
|
|
80
|
+
| toneMapping | aces | aces / neutral |
|
|
81
|
+
|
|
82
|
+
额外构造参数 `pixelRatioCap` 默认 2,必须为正有限数。HDR / EXR 经 PMREM 预过滤绑定 scene.environment,sRGB 输出,默认不增加补光灯、SSAO 或地面阴影。`getConfig()` 返回当前完整配置;`DEFAULT_RENDER_CONFIG` 是只读默认值。
|
|
83
|
+
|
|
84
|
+
`resize()` 自动跟踪容器尺寸,也可手动调用;不重置用户旋转/缩放,需要重新取景时调用 `fit()`。页面隐藏或 WebGL context 丢失时暂停渲染循环,恢复后继续。
|
|
85
|
+
|
|
86
|
+
## 验证边界
|
|
87
|
+
|
|
88
|
+
单元测试使用真实 Three.js 几何体和材质、模拟 WebGL/加载器,覆盖配置校验、替换与失败保留、迟到结果隔离、PMREM 生命周期、本地 URL 回收及卸载。它们不等同于 GPU 视觉验收;模型的具体材质效果、压缩解码、设备兼容与上下文恢复后的画面仍需实际浏览器验证。
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RenderConfig {
|
|
2
|
+
/** Environment lighting strength, 0–3. */
|
|
3
|
+
intensity: number;
|
|
4
|
+
/** Tone mapping exposure, 0.1–3. */
|
|
5
|
+
exposure: number;
|
|
6
|
+
/** Environment yaw in degrees, -180–180. */
|
|
7
|
+
rotation: number;
|
|
8
|
+
/** Model yaw in degrees, -180–180. */
|
|
9
|
+
modelRotation: number;
|
|
10
|
+
/** Opaque background color in #RRGGBB format. */
|
|
11
|
+
background: string;
|
|
12
|
+
panorama: boolean;
|
|
13
|
+
autoRotate: boolean;
|
|
14
|
+
wireframe: boolean;
|
|
15
|
+
toneMapping: 'aces' | 'neutral';
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_RENDER_CONFIG: Readonly<RenderConfig>;
|
|
18
|
+
/** Reject invalid patches atomically, without changing the current configuration. */
|
|
19
|
+
export declare function resolveConfig(patch: Partial<RenderConfig>, current?: Readonly<RenderConfig>): RenderConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const DEFAULT_RENDER_CONFIG = Object.freeze({
|
|
2
|
+
intensity: 1, exposure: 1, rotation: 35, modelRotation: 0,
|
|
3
|
+
background: '#24262b', panorama: false, autoRotate: false,
|
|
4
|
+
wireframe: false, toneMapping: 'aces'
|
|
5
|
+
});
|
|
6
|
+
/** Reject invalid patches atomically, without changing the current configuration. */
|
|
7
|
+
export function resolveConfig(patch, current = DEFAULT_RENDER_CONFIG) {
|
|
8
|
+
for (const key of Object.keys(patch)) {
|
|
9
|
+
if (!Object.hasOwn(DEFAULT_RENDER_CONFIG, key))
|
|
10
|
+
throw new TypeError(`Unknown render option: ${key}`);
|
|
11
|
+
}
|
|
12
|
+
const result = { ...current, ...patch };
|
|
13
|
+
for (const [key, min, max] of [
|
|
14
|
+
['intensity', 0, 3], ['exposure', 0.1, 3], ['rotation', -180, 180], ['modelRotation', -180, 180]
|
|
15
|
+
]) {
|
|
16
|
+
const value = result[key];
|
|
17
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
|
|
18
|
+
throw new RangeError(`${key} must be a finite number between ${min} and ${max}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
for (const key of ['panorama', 'autoRotate', 'wireframe']) {
|
|
22
|
+
if (typeof result[key] !== 'boolean')
|
|
23
|
+
throw new TypeError(`${key} must be a boolean`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof result.background !== 'string' || !/^#[\da-f]{6}$/i.test(result.background))
|
|
26
|
+
throw new TypeError('background must be #RRGGBB');
|
|
27
|
+
if (!['aces', 'neutral'].includes(result.toneMapping))
|
|
28
|
+
throw new TypeError('toneMapping must be aces or neutral');
|
|
29
|
+
return result;
|
|
30
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
3
|
+
import { type RenderConfig } from './config.js';
|
|
4
|
+
export { DEFAULT_RENDER_CONFIG, type RenderConfig } from './config.js';
|
|
5
|
+
export type AssetSource = string | File;
|
|
6
|
+
export type EnvironmentFormat = 'hdr' | 'exr';
|
|
7
|
+
export interface AvatarRendererOptions {
|
|
8
|
+
/** Must have a non-zero width and height. Only this container is observed. */
|
|
9
|
+
container: HTMLElement;
|
|
10
|
+
config?: Partial<RenderConfig>;
|
|
11
|
+
/** Cap device pixel ratio; defaults to 2. */
|
|
12
|
+
pixelRatioCap?: number;
|
|
13
|
+
/** Configure optional Draco/KTX2 decoders on the SDK-owned loader. */
|
|
14
|
+
configureModelLoader?: (loader: GLTFLoader, renderer: THREE.WebGLRenderer) => void;
|
|
15
|
+
}
|
|
16
|
+
/** Browser-only renderer; importing this module does not create a canvas. */
|
|
17
|
+
export declare class AvatarRenderer {
|
|
18
|
+
private readonly container;
|
|
19
|
+
private readonly scene;
|
|
20
|
+
private readonly camera;
|
|
21
|
+
private readonly renderer;
|
|
22
|
+
private readonly controls;
|
|
23
|
+
private readonly observer;
|
|
24
|
+
private readonly loader;
|
|
25
|
+
private config;
|
|
26
|
+
private model?;
|
|
27
|
+
private environment?;
|
|
28
|
+
private environmentTarget?;
|
|
29
|
+
private disposed;
|
|
30
|
+
private contextLost;
|
|
31
|
+
private frame;
|
|
32
|
+
private lastTime;
|
|
33
|
+
private modelRequest;
|
|
34
|
+
private environmentRequest;
|
|
35
|
+
constructor(options: AvatarRendererOptions);
|
|
36
|
+
getConfig(): RenderConfig;
|
|
37
|
+
setConfig(patch: Partial<RenderConfig>): void;
|
|
38
|
+
/** Restore library defaults and the front-facing camera. */
|
|
39
|
+
reset(): void;
|
|
40
|
+
/** Latest request wins. Failed replacements preserve the last valid model. */
|
|
41
|
+
loadModel(source: AssetSource): Promise<void>;
|
|
42
|
+
/** Format is inferred from file name or URL path; pass it explicitly for opaque URLs. */
|
|
43
|
+
loadEnvironment(source: AssetSource, format?: EnvironmentFormat): Promise<void>;
|
|
44
|
+
/** Fit the entire model and return the camera to the front. */
|
|
45
|
+
fit(): void;
|
|
46
|
+
/** Resize without changing the user's orbit/zoom. Called automatically by ResizeObserver. */
|
|
47
|
+
resize(): void;
|
|
48
|
+
/** Idempotent. In-flight results will be discarded and their resources released. */
|
|
49
|
+
dispose(): void;
|
|
50
|
+
private assertActive;
|
|
51
|
+
private assertCurrent;
|
|
52
|
+
private applyConfig;
|
|
53
|
+
private onContextLost;
|
|
54
|
+
private onContextRestored;
|
|
55
|
+
private syncLoop;
|
|
56
|
+
private render;
|
|
57
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
3
|
+
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
|
|
4
|
+
import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
|
|
5
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
6
|
+
import { DEFAULT_RENDER_CONFIG, resolveConfig } from './config.js';
|
|
7
|
+
export { DEFAULT_RENDER_CONFIG } from './config.js';
|
|
8
|
+
function disposeModel(root) {
|
|
9
|
+
const geometries = new Set();
|
|
10
|
+
const materials = new Set();
|
|
11
|
+
const textures = new Set();
|
|
12
|
+
const skeletons = new Set();
|
|
13
|
+
root.traverse((node) => {
|
|
14
|
+
const mesh = node;
|
|
15
|
+
if (!mesh.isMesh)
|
|
16
|
+
return;
|
|
17
|
+
if (mesh.geometry)
|
|
18
|
+
geometries.add(mesh.geometry);
|
|
19
|
+
if (mesh.isSkinnedMesh)
|
|
20
|
+
skeletons.add(mesh.skeleton);
|
|
21
|
+
for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
|
|
22
|
+
materials.add(material);
|
|
23
|
+
for (const value of Object.values(material))
|
|
24
|
+
if (value instanceof THREE.Texture)
|
|
25
|
+
textures.add(value);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
textures.forEach((texture) => texture.dispose());
|
|
29
|
+
materials.forEach((material) => material.dispose());
|
|
30
|
+
geometries.forEach((geometry) => geometry.dispose());
|
|
31
|
+
skeletons.forEach((skeleton) => skeleton.dispose());
|
|
32
|
+
}
|
|
33
|
+
function assetUrl(source) {
|
|
34
|
+
if (typeof source === 'string') {
|
|
35
|
+
if (!source.trim())
|
|
36
|
+
throw new TypeError('Asset URL cannot be empty');
|
|
37
|
+
return { url: source, release: () => { } };
|
|
38
|
+
}
|
|
39
|
+
const url = URL.createObjectURL(source);
|
|
40
|
+
return { url, release: () => URL.revokeObjectURL(url) };
|
|
41
|
+
}
|
|
42
|
+
/** Browser-only renderer; importing this module does not create a canvas. */
|
|
43
|
+
export class AvatarRenderer {
|
|
44
|
+
container;
|
|
45
|
+
scene = new THREE.Scene();
|
|
46
|
+
camera = new THREE.PerspectiveCamera(35, 1, 0.01, 1000);
|
|
47
|
+
renderer;
|
|
48
|
+
controls;
|
|
49
|
+
observer;
|
|
50
|
+
loader = new GLTFLoader();
|
|
51
|
+
config;
|
|
52
|
+
model;
|
|
53
|
+
environment;
|
|
54
|
+
environmentTarget;
|
|
55
|
+
disposed = false;
|
|
56
|
+
contextLost = false;
|
|
57
|
+
frame;
|
|
58
|
+
lastTime = 0;
|
|
59
|
+
modelRequest = 0;
|
|
60
|
+
environmentRequest = 0;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.config = resolveConfig(options.config ?? {});
|
|
63
|
+
const ratio = options.pixelRatioCap ?? 2;
|
|
64
|
+
if (!Number.isFinite(ratio) || ratio <= 0)
|
|
65
|
+
throw new RangeError('pixelRatioCap must be positive');
|
|
66
|
+
this.container = options.container;
|
|
67
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
68
|
+
try {
|
|
69
|
+
options.configureModelLoader?.(this.loader, this.renderer);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
this.renderer.dispose();
|
|
73
|
+
this.renderer.forceContextLoss();
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
77
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, ratio));
|
|
78
|
+
this.renderer.domElement.style.cssText = 'display:block;width:100%;height:100%';
|
|
79
|
+
this.renderer.domElement.setAttribute('aria-label', '3D model preview');
|
|
80
|
+
this.container.appendChild(this.renderer.domElement);
|
|
81
|
+
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
82
|
+
this.controls.enableDamping = true;
|
|
83
|
+
this.observer = new ResizeObserver(() => this.resize());
|
|
84
|
+
this.observer.observe(this.container);
|
|
85
|
+
this.renderer.domElement.addEventListener('webglcontextlost', this.onContextLost);
|
|
86
|
+
this.renderer.domElement.addEventListener('webglcontextrestored', this.onContextRestored);
|
|
87
|
+
document.addEventListener('visibilitychange', this.syncLoop);
|
|
88
|
+
this.applyConfig();
|
|
89
|
+
this.resize();
|
|
90
|
+
this.syncLoop();
|
|
91
|
+
}
|
|
92
|
+
getConfig() { return { ...this.config }; }
|
|
93
|
+
setConfig(patch) {
|
|
94
|
+
this.assertActive();
|
|
95
|
+
this.config = resolveConfig(patch, this.config);
|
|
96
|
+
this.applyConfig();
|
|
97
|
+
}
|
|
98
|
+
/** Restore library defaults and the front-facing camera. */
|
|
99
|
+
reset() {
|
|
100
|
+
this.setConfig({ ...DEFAULT_RENDER_CONFIG });
|
|
101
|
+
this.fit();
|
|
102
|
+
}
|
|
103
|
+
/** Latest request wins. Failed replacements preserve the last valid model. */
|
|
104
|
+
async loadModel(source) {
|
|
105
|
+
this.assertActive();
|
|
106
|
+
const request = ++this.modelRequest;
|
|
107
|
+
if (typeof source !== 'string' && !/\.glb$/i.test(source.name))
|
|
108
|
+
throw new TypeError('Select a .glb file');
|
|
109
|
+
const asset = assetUrl(source);
|
|
110
|
+
let loaded;
|
|
111
|
+
try {
|
|
112
|
+
loaded = (await this.loader.loadAsync(asset.url)).scene;
|
|
113
|
+
this.assertCurrent(request, 'model');
|
|
114
|
+
const bounds = new THREE.Box3().setFromObject(loaded);
|
|
115
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
116
|
+
const extent = Math.max(size.x, size.y, size.z);
|
|
117
|
+
if (!Number.isFinite(extent) || extent <= 0)
|
|
118
|
+
throw new Error('Model has no renderable bounds');
|
|
119
|
+
// Center and normalize through wrappers, keeping authored root transforms intact.
|
|
120
|
+
const centered = new THREE.Group();
|
|
121
|
+
centered.position.copy(bounds.getCenter(new THREE.Vector3())).negate();
|
|
122
|
+
centered.add(loaded);
|
|
123
|
+
const wrapper = new THREE.Group();
|
|
124
|
+
wrapper.scale.setScalar(2 / extent);
|
|
125
|
+
wrapper.add(centered);
|
|
126
|
+
const old = this.model;
|
|
127
|
+
this.model = wrapper;
|
|
128
|
+
this.scene.add(wrapper);
|
|
129
|
+
if (old) {
|
|
130
|
+
this.scene.remove(old);
|
|
131
|
+
disposeModel(old);
|
|
132
|
+
}
|
|
133
|
+
loaded = undefined;
|
|
134
|
+
this.applyConfig();
|
|
135
|
+
this.fit();
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (loaded)
|
|
139
|
+
disposeModel(loaded);
|
|
140
|
+
this.assertCurrent(request, 'model');
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
asset.release();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Format is inferred from file name or URL path; pass it explicitly for opaque URLs. */
|
|
148
|
+
async loadEnvironment(source, format) {
|
|
149
|
+
this.assertActive();
|
|
150
|
+
const request = ++this.environmentRequest;
|
|
151
|
+
const name = typeof source === 'string' ? source.split(/[?#]/)[0] : source.name;
|
|
152
|
+
const selected = format ?? (/\.exr$/i.test(name) ? 'exr' : /\.hdr$/i.test(name) ? 'hdr' : undefined);
|
|
153
|
+
if (selected !== 'hdr' && selected !== 'exr')
|
|
154
|
+
throw new TypeError('Environment format must be hdr or exr');
|
|
155
|
+
const asset = assetUrl(source);
|
|
156
|
+
let texture;
|
|
157
|
+
let target;
|
|
158
|
+
try {
|
|
159
|
+
texture = await (selected === 'exr' ? new EXRLoader() : new RGBELoader()).loadAsync(asset.url);
|
|
160
|
+
this.assertCurrent(request, 'environment');
|
|
161
|
+
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
162
|
+
const pmrem = new THREE.PMREMGenerator(this.renderer);
|
|
163
|
+
try {
|
|
164
|
+
target = pmrem.fromEquirectangular(texture);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
pmrem.dispose();
|
|
168
|
+
}
|
|
169
|
+
const oldTexture = this.environment;
|
|
170
|
+
const oldTarget = this.environmentTarget;
|
|
171
|
+
this.environment = texture;
|
|
172
|
+
this.environmentTarget = target;
|
|
173
|
+
this.scene.environment = target.texture;
|
|
174
|
+
this.applyConfig();
|
|
175
|
+
texture = undefined;
|
|
176
|
+
target = undefined;
|
|
177
|
+
oldTarget?.dispose();
|
|
178
|
+
oldTexture?.dispose();
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
target?.dispose();
|
|
182
|
+
texture?.dispose();
|
|
183
|
+
this.assertCurrent(request, 'environment');
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
asset.release();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Fit the entire model and return the camera to the front. */
|
|
191
|
+
fit() {
|
|
192
|
+
this.assertActive();
|
|
193
|
+
if (!this.model)
|
|
194
|
+
return;
|
|
195
|
+
const bounds = new THREE.Box3().setFromObject(this.model);
|
|
196
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
197
|
+
const target = bounds.getCenter(new THREE.Vector3());
|
|
198
|
+
const verticalFov = THREE.MathUtils.degToRad(this.camera.fov);
|
|
199
|
+
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * this.camera.aspect);
|
|
200
|
+
const distance = Math.max(size.y / (2 * Math.tan(verticalFov / 2)), size.x / (2 * Math.tan(horizontalFov / 2)), size.z) * 1.15;
|
|
201
|
+
this.controls.target.copy(target);
|
|
202
|
+
this.camera.position.copy(target).add(new THREE.Vector3(0, 0.08, 1).normalize().multiplyScalar(distance));
|
|
203
|
+
this.camera.near = Math.max(distance / 100, 0.001);
|
|
204
|
+
this.camera.far = Math.max(distance * 100, 100);
|
|
205
|
+
this.controls.minDistance = distance * 0.2;
|
|
206
|
+
this.controls.maxDistance = distance * 5;
|
|
207
|
+
this.camera.updateProjectionMatrix();
|
|
208
|
+
this.controls.update();
|
|
209
|
+
}
|
|
210
|
+
/** Resize without changing the user's orbit/zoom. Called automatically by ResizeObserver. */
|
|
211
|
+
resize() {
|
|
212
|
+
this.assertActive();
|
|
213
|
+
const width = Math.max(this.container.clientWidth, 1);
|
|
214
|
+
const height = Math.max(this.container.clientHeight, 1);
|
|
215
|
+
this.renderer.setSize(width, height, false);
|
|
216
|
+
this.camera.aspect = width / height;
|
|
217
|
+
this.camera.updateProjectionMatrix();
|
|
218
|
+
}
|
|
219
|
+
/** Idempotent. In-flight results will be discarded and their resources released. */
|
|
220
|
+
dispose() {
|
|
221
|
+
if (this.disposed)
|
|
222
|
+
return;
|
|
223
|
+
this.disposed = true;
|
|
224
|
+
if (this.frame !== undefined)
|
|
225
|
+
cancelAnimationFrame(this.frame);
|
|
226
|
+
this.observer.disconnect();
|
|
227
|
+
document.removeEventListener('visibilitychange', this.syncLoop);
|
|
228
|
+
this.renderer.domElement.removeEventListener('webglcontextlost', this.onContextLost);
|
|
229
|
+
this.renderer.domElement.removeEventListener('webglcontextrestored', this.onContextRestored);
|
|
230
|
+
this.controls.dispose();
|
|
231
|
+
this.scene.environment = null;
|
|
232
|
+
this.scene.background = null;
|
|
233
|
+
this.scene.clear();
|
|
234
|
+
if (this.model)
|
|
235
|
+
disposeModel(this.model);
|
|
236
|
+
this.environmentTarget?.dispose();
|
|
237
|
+
this.environment?.dispose();
|
|
238
|
+
this.model = undefined;
|
|
239
|
+
this.environment = undefined;
|
|
240
|
+
this.environmentTarget = undefined;
|
|
241
|
+
this.renderer.dispose();
|
|
242
|
+
this.renderer.forceContextLoss();
|
|
243
|
+
this.renderer.domElement.remove();
|
|
244
|
+
}
|
|
245
|
+
assertActive() {
|
|
246
|
+
if (this.disposed)
|
|
247
|
+
throw new Error('AvatarRenderer has been disposed');
|
|
248
|
+
}
|
|
249
|
+
assertCurrent(request, kind) {
|
|
250
|
+
if (this.disposed || request !== (kind === 'model' ? this.modelRequest : this.environmentRequest)) {
|
|
251
|
+
throw new DOMException('Loading was superseded or renderer was disposed', 'AbortError');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
applyConfig() {
|
|
255
|
+
const config = this.config;
|
|
256
|
+
this.scene.environmentIntensity = config.intensity;
|
|
257
|
+
this.scene.backgroundIntensity = config.intensity;
|
|
258
|
+
this.scene.environmentRotation.y = THREE.MathUtils.degToRad(config.rotation);
|
|
259
|
+
this.scene.backgroundRotation.y = THREE.MathUtils.degToRad(config.rotation);
|
|
260
|
+
this.scene.background = config.panorama && this.environment ? this.environment : new THREE.Color(config.background);
|
|
261
|
+
this.renderer.toneMappingExposure = config.exposure;
|
|
262
|
+
this.renderer.toneMapping = config.toneMapping === 'aces' ? THREE.ACESFilmicToneMapping : THREE.NeutralToneMapping;
|
|
263
|
+
this.controls.autoRotate = config.autoRotate;
|
|
264
|
+
if (this.model) {
|
|
265
|
+
this.model.rotation.y = THREE.MathUtils.degToRad(config.modelRotation);
|
|
266
|
+
this.model.traverse((node) => {
|
|
267
|
+
const mesh = node;
|
|
268
|
+
if (!mesh.isMesh)
|
|
269
|
+
return;
|
|
270
|
+
for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
|
|
271
|
+
if ('wireframe' in material)
|
|
272
|
+
material.wireframe = config.wireframe;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
onContextLost = (event) => { event.preventDefault(); this.contextLost = true; this.syncLoop(); };
|
|
278
|
+
onContextRestored = () => {
|
|
279
|
+
if (this.disposed)
|
|
280
|
+
return;
|
|
281
|
+
// Render-target contents are lost with the context; rebuild filtered IBL.
|
|
282
|
+
if (this.environment) {
|
|
283
|
+
const pmrem = new THREE.PMREMGenerator(this.renderer);
|
|
284
|
+
try {
|
|
285
|
+
const target = pmrem.fromEquirectangular(this.environment);
|
|
286
|
+
this.scene.environment = target.texture;
|
|
287
|
+
this.environmentTarget?.dispose();
|
|
288
|
+
this.environmentTarget = target;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
pmrem.dispose();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
this.contextLost = false;
|
|
295
|
+
this.syncLoop();
|
|
296
|
+
};
|
|
297
|
+
syncLoop = () => {
|
|
298
|
+
if (this.frame !== undefined)
|
|
299
|
+
cancelAnimationFrame(this.frame);
|
|
300
|
+
this.frame = undefined;
|
|
301
|
+
this.lastTime = 0;
|
|
302
|
+
if (!this.disposed && !document.hidden && !this.contextLost)
|
|
303
|
+
this.frame = requestAnimationFrame(this.render);
|
|
304
|
+
};
|
|
305
|
+
render = (time) => {
|
|
306
|
+
if (this.disposed)
|
|
307
|
+
return;
|
|
308
|
+
const delta = this.lastTime ? Math.min((time - this.lastTime) / 1000, 0.05) : 0;
|
|
309
|
+
this.lastTime = time;
|
|
310
|
+
this.controls.update(delta);
|
|
311
|
+
this.renderer.render(this.scene, this.camera);
|
|
312
|
+
this.frame = requestAnimationFrame(this.render);
|
|
313
|
+
};
|
|
314
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avatar-platform/avatar-render",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-independent GLB viewer with configurable HDR/EXR studio lighting",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
9
|
+
"files": ["dist", "README.md"],
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"registry": "https://registry.npmjs.org/"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"prepack": "pnpm build"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": { "three": "^0.180.0" },
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"three": "^0.180.0",
|
|
24
|
+
"@types/three": "^0.180.0",
|
|
25
|
+
"typescript": "~5.6.2",
|
|
26
|
+
"vitest": "^2.1.9",
|
|
27
|
+
"jsdom": "^25.0.1"
|
|
28
|
+
}
|
|
29
|
+
}
|