@avatar-platform/avatar-render 0.0.0-stage → 0.2.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 +105 -2
- package/assets/overcast.hdr +0 -0
- package/assets/soft-studio.hdr +0 -0
- package/assets/studio.hdr +0 -0
- package/assets/window.hdr +0 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +30 -0
- package/dist/environments.d.ts +20 -0
- package/dist/environments.js +8 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +324 -0
- package/package.json +38 -4
package/README.md
CHANGED
|
@@ -1,3 +1,106 @@
|
|
|
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
|
+
- 包内置原摄影棚和三套柔光 HDR(合计约 4.34 MB);模型、其他 HDR / EXR、凭据、业务地址不打包。PNG 全景预览图不作为 HDR 环境输入。
|
|
65
|
+
|
|
66
|
+
## 内置 HDR 场景
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { ENVIRONMENT_PRESETS } from '@avatar-platform/avatar-render';
|
|
70
|
+
|
|
71
|
+
await viewer.loadEnvironmentPreset(); // studio:原默认摄影棚
|
|
72
|
+
await viewer.loadEnvironmentPreset('overcast'); // 阴天户外 · 柔和天空
|
|
73
|
+
await viewer.loadEnvironmentPreset('window'); // 室内大窗 · 柔光
|
|
74
|
+
await viewer.loadEnvironmentPreset('soft-studio'); // 均匀展示棚 · 低反差
|
|
75
|
+
// ENVIRONMENT_PRESETS 的只读 id / name / url 可用于创建宿主下拉菜单。
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
构造器仍不自动加载环境;需要默认摄影棚时显式调用 `loadEnvironmentPreset()`。切换只替换环境照明,保留曝光、强度、旋转及模型材质,加载失败保留当前环境。`reset()` 只重置渲染配置和视角,不切换 HDR。`/render-lab` 默认使用原摄影棚,提供四个场景及自定义文件入口。
|
|
79
|
+
|
|
80
|
+
HDR 随 npm 包的 `assets/` 分发,通过相对模块的 `new URL(..., import.meta.url)` 解析;Vite 等支持此写法的构建器会复制资源。直接以原生 ESM 部署时,保留 `dist/` 与 `assets/` 的相对目录;其他构建器需配置 HDR 静态资源处理,或部署 assets 后使用 `loadEnvironment(url, 'hdr')`。按选择加载一套,不会预加载全部 HDR。
|
|
81
|
+
|
|
82
|
+
新增三套为建模师提供的原创程序化 2048×1024 Radiance RGBE、线性光环境,分别为微冷天空、暖中性侧窗和中性低反差棚光;名称描述照明风格,不是真实场景照片。可保持强度 1 对比,过亮时尝试 0.7;不覆盖原模型 roughness / metalness。包仅包含 HDR,不包含材质球测试或全景预览 PNG。
|
|
83
|
+
|
|
84
|
+
## 配置
|
|
85
|
+
|
|
86
|
+
`config` 和 `setConfig()` 都接受 `Partial<RenderConfig>`。角度单位为度;无效值抛错,整次更新不生效,不静默截断。
|
|
87
|
+
|
|
88
|
+
| 字段 | 默认值 | 支持范围 / 含义 |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| intensity | 1 | 0–3,环境照明与全景背景强度 |
|
|
91
|
+
| exposure | 1 | 0.1–3,色彩映射曝光 |
|
|
92
|
+
| rotation | 35 | -180–180,环境照明和全景同步绕 Y 轴旋转 |
|
|
93
|
+
| modelRotation | 0 | -180–180,模型绕 Y 轴旋转 |
|
|
94
|
+
| background | #24262b | #RRGGBB 背景颜色 |
|
|
95
|
+
| panorama | false | 显示 HDR/EXR 全景;无环境时仍显示背景色 |
|
|
96
|
+
| autoRotate | false | 相机自动环绕模型 |
|
|
97
|
+
| wireframe | false | 线框检查 |
|
|
98
|
+
| toneMapping | aces | aces / neutral |
|
|
99
|
+
|
|
100
|
+
额外构造参数 `pixelRatioCap` 默认 2,必须为正有限数。HDR / EXR 经 PMREM 预过滤绑定 scene.environment,sRGB 输出,默认不增加补光灯、SSAO 或地面阴影。`getConfig()` 返回当前完整配置;`DEFAULT_RENDER_CONFIG` 是只读默认值。
|
|
101
|
+
|
|
102
|
+
`resize()` 自动跟踪容器尺寸,也可手动调用;不重置用户旋转/缩放,需要重新取景时调用 `fit()`。页面隐藏或 WebGL context 丢失时暂停渲染循环,恢复后继续。
|
|
103
|
+
|
|
104
|
+
## 验证边界
|
|
105
|
+
|
|
106
|
+
单元测试使用真实 Three.js 几何体和材质、模拟 WebGL/加载器,覆盖配置校验、替换与失败保留、迟到结果隔离、PMREM 生命周期、本地 URL 回收及卸载。它们不等同于 GPU 视觉验收;模型的具体材质效果、压缩解码、设备兼容与上下文恢复后的画面仍需实际浏览器验证。
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Bundled HDR assets, resolved relative to this module by browser bundlers. */
|
|
2
|
+
export declare const ENVIRONMENT_PRESETS: readonly [Readonly<{
|
|
3
|
+
id: "studio";
|
|
4
|
+
name: "默认摄影棚";
|
|
5
|
+
url: string;
|
|
6
|
+
}>, Readonly<{
|
|
7
|
+
id: "overcast";
|
|
8
|
+
name: "阴天户外 · 柔和天空";
|
|
9
|
+
url: string;
|
|
10
|
+
}>, Readonly<{
|
|
11
|
+
id: "window";
|
|
12
|
+
name: "室内大窗 · 柔光";
|
|
13
|
+
url: string;
|
|
14
|
+
}>, Readonly<{
|
|
15
|
+
id: "soft-studio";
|
|
16
|
+
name: "均匀展示棚 · 低反差";
|
|
17
|
+
url: string;
|
|
18
|
+
}>];
|
|
19
|
+
export type EnvironmentPresetId = typeof ENVIRONMENT_PRESETS[number]['id'];
|
|
20
|
+
export declare const DEFAULT_ENVIRONMENT_PRESET: EnvironmentPresetId;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Bundled HDR assets, resolved relative to this module by browser bundlers. */
|
|
2
|
+
export const ENVIRONMENT_PRESETS = Object.freeze([
|
|
3
|
+
Object.freeze({ id: 'studio', name: '默认摄影棚', url: new URL('../assets/studio.hdr', import.meta.url).href }),
|
|
4
|
+
Object.freeze({ id: 'overcast', name: '阴天户外 · 柔和天空', url: new URL('../assets/overcast.hdr', import.meta.url).href }),
|
|
5
|
+
Object.freeze({ id: 'window', name: '室内大窗 · 柔光', url: new URL('../assets/window.hdr', import.meta.url).href }),
|
|
6
|
+
Object.freeze({ id: 'soft-studio', name: '均匀展示棚 · 低反差', url: new URL('../assets/soft-studio.hdr', import.meta.url).href })
|
|
7
|
+
]);
|
|
8
|
+
export const DEFAULT_ENVIRONMENT_PRESET = 'studio';
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
3
|
+
import { type RenderConfig } from './config.js';
|
|
4
|
+
import { type EnvironmentPresetId } from './environments.js';
|
|
5
|
+
export { DEFAULT_ENVIRONMENT_PRESET, ENVIRONMENT_PRESETS, type EnvironmentPresetId } from './environments.js';
|
|
6
|
+
export { DEFAULT_RENDER_CONFIG, type RenderConfig } from './config.js';
|
|
7
|
+
export type AssetSource = string | File;
|
|
8
|
+
export type EnvironmentFormat = 'hdr' | 'exr';
|
|
9
|
+
export interface AvatarRendererOptions {
|
|
10
|
+
/** Must have a non-zero width and height. Only this container is observed. */
|
|
11
|
+
container: HTMLElement;
|
|
12
|
+
config?: Partial<RenderConfig>;
|
|
13
|
+
/** Cap device pixel ratio; defaults to 2. */
|
|
14
|
+
pixelRatioCap?: number;
|
|
15
|
+
/** Configure optional Draco/KTX2 decoders on the SDK-owned loader. */
|
|
16
|
+
configureModelLoader?: (loader: GLTFLoader, renderer: THREE.WebGLRenderer) => void;
|
|
17
|
+
}
|
|
18
|
+
/** Browser-only renderer; importing this module does not create a canvas. */
|
|
19
|
+
export declare class AvatarRenderer {
|
|
20
|
+
private readonly container;
|
|
21
|
+
private readonly scene;
|
|
22
|
+
private readonly camera;
|
|
23
|
+
private readonly renderer;
|
|
24
|
+
private readonly controls;
|
|
25
|
+
private readonly observer;
|
|
26
|
+
private readonly loader;
|
|
27
|
+
private config;
|
|
28
|
+
private model?;
|
|
29
|
+
private environment?;
|
|
30
|
+
private environmentTarget?;
|
|
31
|
+
private disposed;
|
|
32
|
+
private contextLost;
|
|
33
|
+
private frame;
|
|
34
|
+
private lastTime;
|
|
35
|
+
private modelRequest;
|
|
36
|
+
private environmentRequest;
|
|
37
|
+
constructor(options: AvatarRendererOptions);
|
|
38
|
+
getConfig(): RenderConfig;
|
|
39
|
+
setConfig(patch: Partial<RenderConfig>): void;
|
|
40
|
+
/** Restore library defaults and the front-facing camera. */
|
|
41
|
+
reset(): void;
|
|
42
|
+
/** Latest request wins. Failed replacements preserve the last valid model. */
|
|
43
|
+
loadModel(source: AssetSource): Promise<void>;
|
|
44
|
+
/** Load bundled lighting without changing render settings. Defaults to the original studio. */
|
|
45
|
+
loadEnvironmentPreset(id?: EnvironmentPresetId): Promise<void>;
|
|
46
|
+
/** Format is inferred from file name or URL path; pass it explicitly for opaque URLs. */
|
|
47
|
+
loadEnvironment(source: AssetSource, format?: EnvironmentFormat): Promise<void>;
|
|
48
|
+
/** Fit the entire model and return the camera to the front. */
|
|
49
|
+
fit(): void;
|
|
50
|
+
/** Resize without changing the user's orbit/zoom. Called automatically by ResizeObserver. */
|
|
51
|
+
resize(): void;
|
|
52
|
+
/** Idempotent. In-flight results will be discarded and their resources released. */
|
|
53
|
+
dispose(): void;
|
|
54
|
+
private assertActive;
|
|
55
|
+
private assertCurrent;
|
|
56
|
+
private applyConfig;
|
|
57
|
+
private onContextLost;
|
|
58
|
+
private onContextRestored;
|
|
59
|
+
private syncLoop;
|
|
60
|
+
private render;
|
|
61
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
import { DEFAULT_ENVIRONMENT_PRESET, ENVIRONMENT_PRESETS } from './environments.js';
|
|
8
|
+
export { DEFAULT_ENVIRONMENT_PRESET, ENVIRONMENT_PRESETS } from './environments.js';
|
|
9
|
+
export { DEFAULT_RENDER_CONFIG } from './config.js';
|
|
10
|
+
function disposeModel(root) {
|
|
11
|
+
const geometries = new Set();
|
|
12
|
+
const materials = new Set();
|
|
13
|
+
const textures = new Set();
|
|
14
|
+
const skeletons = new Set();
|
|
15
|
+
root.traverse((node) => {
|
|
16
|
+
const mesh = node;
|
|
17
|
+
if (!mesh.isMesh)
|
|
18
|
+
return;
|
|
19
|
+
if (mesh.geometry)
|
|
20
|
+
geometries.add(mesh.geometry);
|
|
21
|
+
if (mesh.isSkinnedMesh)
|
|
22
|
+
skeletons.add(mesh.skeleton);
|
|
23
|
+
for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
|
|
24
|
+
materials.add(material);
|
|
25
|
+
for (const value of Object.values(material))
|
|
26
|
+
if (value instanceof THREE.Texture)
|
|
27
|
+
textures.add(value);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
textures.forEach((texture) => texture.dispose());
|
|
31
|
+
materials.forEach((material) => material.dispose());
|
|
32
|
+
geometries.forEach((geometry) => geometry.dispose());
|
|
33
|
+
skeletons.forEach((skeleton) => skeleton.dispose());
|
|
34
|
+
}
|
|
35
|
+
function assetUrl(source) {
|
|
36
|
+
if (typeof source === 'string') {
|
|
37
|
+
if (!source.trim())
|
|
38
|
+
throw new TypeError('Asset URL cannot be empty');
|
|
39
|
+
return { url: source, release: () => { } };
|
|
40
|
+
}
|
|
41
|
+
const url = URL.createObjectURL(source);
|
|
42
|
+
return { url, release: () => URL.revokeObjectURL(url) };
|
|
43
|
+
}
|
|
44
|
+
/** Browser-only renderer; importing this module does not create a canvas. */
|
|
45
|
+
export class AvatarRenderer {
|
|
46
|
+
container;
|
|
47
|
+
scene = new THREE.Scene();
|
|
48
|
+
camera = new THREE.PerspectiveCamera(35, 1, 0.01, 1000);
|
|
49
|
+
renderer;
|
|
50
|
+
controls;
|
|
51
|
+
observer;
|
|
52
|
+
loader = new GLTFLoader();
|
|
53
|
+
config;
|
|
54
|
+
model;
|
|
55
|
+
environment;
|
|
56
|
+
environmentTarget;
|
|
57
|
+
disposed = false;
|
|
58
|
+
contextLost = false;
|
|
59
|
+
frame;
|
|
60
|
+
lastTime = 0;
|
|
61
|
+
modelRequest = 0;
|
|
62
|
+
environmentRequest = 0;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.config = resolveConfig(options.config ?? {});
|
|
65
|
+
const ratio = options.pixelRatioCap ?? 2;
|
|
66
|
+
if (!Number.isFinite(ratio) || ratio <= 0)
|
|
67
|
+
throw new RangeError('pixelRatioCap must be positive');
|
|
68
|
+
this.container = options.container;
|
|
69
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
70
|
+
try {
|
|
71
|
+
options.configureModelLoader?.(this.loader, this.renderer);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.renderer.dispose();
|
|
75
|
+
this.renderer.forceContextLoss();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
79
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, ratio));
|
|
80
|
+
this.renderer.domElement.style.cssText = 'display:block;width:100%;height:100%';
|
|
81
|
+
this.renderer.domElement.setAttribute('aria-label', '3D model preview');
|
|
82
|
+
this.container.appendChild(this.renderer.domElement);
|
|
83
|
+
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
84
|
+
this.controls.enableDamping = true;
|
|
85
|
+
this.observer = new ResizeObserver(() => this.resize());
|
|
86
|
+
this.observer.observe(this.container);
|
|
87
|
+
this.renderer.domElement.addEventListener('webglcontextlost', this.onContextLost);
|
|
88
|
+
this.renderer.domElement.addEventListener('webglcontextrestored', this.onContextRestored);
|
|
89
|
+
document.addEventListener('visibilitychange', this.syncLoop);
|
|
90
|
+
this.applyConfig();
|
|
91
|
+
this.resize();
|
|
92
|
+
this.syncLoop();
|
|
93
|
+
}
|
|
94
|
+
getConfig() { return { ...this.config }; }
|
|
95
|
+
setConfig(patch) {
|
|
96
|
+
this.assertActive();
|
|
97
|
+
this.config = resolveConfig(patch, this.config);
|
|
98
|
+
this.applyConfig();
|
|
99
|
+
}
|
|
100
|
+
/** Restore library defaults and the front-facing camera. */
|
|
101
|
+
reset() {
|
|
102
|
+
this.setConfig({ ...DEFAULT_RENDER_CONFIG });
|
|
103
|
+
this.fit();
|
|
104
|
+
}
|
|
105
|
+
/** Latest request wins. Failed replacements preserve the last valid model. */
|
|
106
|
+
async loadModel(source) {
|
|
107
|
+
this.assertActive();
|
|
108
|
+
const request = ++this.modelRequest;
|
|
109
|
+
if (typeof source !== 'string' && !/\.glb$/i.test(source.name))
|
|
110
|
+
throw new TypeError('Select a .glb file');
|
|
111
|
+
const asset = assetUrl(source);
|
|
112
|
+
let loaded;
|
|
113
|
+
try {
|
|
114
|
+
loaded = (await this.loader.loadAsync(asset.url)).scene;
|
|
115
|
+
this.assertCurrent(request, 'model');
|
|
116
|
+
const bounds = new THREE.Box3().setFromObject(loaded);
|
|
117
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
118
|
+
const extent = Math.max(size.x, size.y, size.z);
|
|
119
|
+
if (!Number.isFinite(extent) || extent <= 0)
|
|
120
|
+
throw new Error('Model has no renderable bounds');
|
|
121
|
+
// Center and normalize through wrappers, keeping authored root transforms intact.
|
|
122
|
+
const centered = new THREE.Group();
|
|
123
|
+
centered.position.copy(bounds.getCenter(new THREE.Vector3())).negate();
|
|
124
|
+
centered.add(loaded);
|
|
125
|
+
const wrapper = new THREE.Group();
|
|
126
|
+
wrapper.scale.setScalar(2 / extent);
|
|
127
|
+
wrapper.add(centered);
|
|
128
|
+
const old = this.model;
|
|
129
|
+
this.model = wrapper;
|
|
130
|
+
this.scene.add(wrapper);
|
|
131
|
+
if (old) {
|
|
132
|
+
this.scene.remove(old);
|
|
133
|
+
disposeModel(old);
|
|
134
|
+
}
|
|
135
|
+
loaded = undefined;
|
|
136
|
+
this.applyConfig();
|
|
137
|
+
this.fit();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (loaded)
|
|
141
|
+
disposeModel(loaded);
|
|
142
|
+
this.assertCurrent(request, 'model');
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
asset.release();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Load bundled lighting without changing render settings. Defaults to the original studio. */
|
|
150
|
+
async loadEnvironmentPreset(id = DEFAULT_ENVIRONMENT_PRESET) {
|
|
151
|
+
this.assertActive();
|
|
152
|
+
const preset = ENVIRONMENT_PRESETS.find((entry) => entry.id === id);
|
|
153
|
+
if (!preset)
|
|
154
|
+
throw new TypeError(`Unknown environment preset: ${id}`);
|
|
155
|
+
await this.loadEnvironment(preset.url, 'hdr');
|
|
156
|
+
}
|
|
157
|
+
/** Format is inferred from file name or URL path; pass it explicitly for opaque URLs. */
|
|
158
|
+
async loadEnvironment(source, format) {
|
|
159
|
+
this.assertActive();
|
|
160
|
+
const request = ++this.environmentRequest;
|
|
161
|
+
const name = typeof source === 'string' ? source.split(/[?#]/)[0] : source.name;
|
|
162
|
+
const selected = format ?? (/\.exr$/i.test(name) ? 'exr' : /\.hdr$/i.test(name) ? 'hdr' : undefined);
|
|
163
|
+
if (selected !== 'hdr' && selected !== 'exr')
|
|
164
|
+
throw new TypeError('Environment format must be hdr or exr');
|
|
165
|
+
const asset = assetUrl(source);
|
|
166
|
+
let texture;
|
|
167
|
+
let target;
|
|
168
|
+
try {
|
|
169
|
+
texture = await (selected === 'exr' ? new EXRLoader() : new RGBELoader()).loadAsync(asset.url);
|
|
170
|
+
this.assertCurrent(request, 'environment');
|
|
171
|
+
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
172
|
+
const pmrem = new THREE.PMREMGenerator(this.renderer);
|
|
173
|
+
try {
|
|
174
|
+
target = pmrem.fromEquirectangular(texture);
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
pmrem.dispose();
|
|
178
|
+
}
|
|
179
|
+
const oldTexture = this.environment;
|
|
180
|
+
const oldTarget = this.environmentTarget;
|
|
181
|
+
this.environment = texture;
|
|
182
|
+
this.environmentTarget = target;
|
|
183
|
+
this.scene.environment = target.texture;
|
|
184
|
+
this.applyConfig();
|
|
185
|
+
texture = undefined;
|
|
186
|
+
target = undefined;
|
|
187
|
+
oldTarget?.dispose();
|
|
188
|
+
oldTexture?.dispose();
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
target?.dispose();
|
|
192
|
+
texture?.dispose();
|
|
193
|
+
this.assertCurrent(request, 'environment');
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
asset.release();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Fit the entire model and return the camera to the front. */
|
|
201
|
+
fit() {
|
|
202
|
+
this.assertActive();
|
|
203
|
+
if (!this.model)
|
|
204
|
+
return;
|
|
205
|
+
const bounds = new THREE.Box3().setFromObject(this.model);
|
|
206
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
207
|
+
const target = bounds.getCenter(new THREE.Vector3());
|
|
208
|
+
const verticalFov = THREE.MathUtils.degToRad(this.camera.fov);
|
|
209
|
+
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * this.camera.aspect);
|
|
210
|
+
const distance = Math.max(size.y / (2 * Math.tan(verticalFov / 2)), size.x / (2 * Math.tan(horizontalFov / 2)), size.z) * 1.15;
|
|
211
|
+
this.controls.target.copy(target);
|
|
212
|
+
this.camera.position.copy(target).add(new THREE.Vector3(0, 0.08, 1).normalize().multiplyScalar(distance));
|
|
213
|
+
this.camera.near = Math.max(distance / 100, 0.001);
|
|
214
|
+
this.camera.far = Math.max(distance * 100, 100);
|
|
215
|
+
this.controls.minDistance = distance * 0.2;
|
|
216
|
+
this.controls.maxDistance = distance * 5;
|
|
217
|
+
this.camera.updateProjectionMatrix();
|
|
218
|
+
this.controls.update();
|
|
219
|
+
}
|
|
220
|
+
/** Resize without changing the user's orbit/zoom. Called automatically by ResizeObserver. */
|
|
221
|
+
resize() {
|
|
222
|
+
this.assertActive();
|
|
223
|
+
const width = Math.max(this.container.clientWidth, 1);
|
|
224
|
+
const height = Math.max(this.container.clientHeight, 1);
|
|
225
|
+
this.renderer.setSize(width, height, false);
|
|
226
|
+
this.camera.aspect = width / height;
|
|
227
|
+
this.camera.updateProjectionMatrix();
|
|
228
|
+
}
|
|
229
|
+
/** Idempotent. In-flight results will be discarded and their resources released. */
|
|
230
|
+
dispose() {
|
|
231
|
+
if (this.disposed)
|
|
232
|
+
return;
|
|
233
|
+
this.disposed = true;
|
|
234
|
+
if (this.frame !== undefined)
|
|
235
|
+
cancelAnimationFrame(this.frame);
|
|
236
|
+
this.observer.disconnect();
|
|
237
|
+
document.removeEventListener('visibilitychange', this.syncLoop);
|
|
238
|
+
this.renderer.domElement.removeEventListener('webglcontextlost', this.onContextLost);
|
|
239
|
+
this.renderer.domElement.removeEventListener('webglcontextrestored', this.onContextRestored);
|
|
240
|
+
this.controls.dispose();
|
|
241
|
+
this.scene.environment = null;
|
|
242
|
+
this.scene.background = null;
|
|
243
|
+
this.scene.clear();
|
|
244
|
+
if (this.model)
|
|
245
|
+
disposeModel(this.model);
|
|
246
|
+
this.environmentTarget?.dispose();
|
|
247
|
+
this.environment?.dispose();
|
|
248
|
+
this.model = undefined;
|
|
249
|
+
this.environment = undefined;
|
|
250
|
+
this.environmentTarget = undefined;
|
|
251
|
+
this.renderer.dispose();
|
|
252
|
+
this.renderer.forceContextLoss();
|
|
253
|
+
this.renderer.domElement.remove();
|
|
254
|
+
}
|
|
255
|
+
assertActive() {
|
|
256
|
+
if (this.disposed)
|
|
257
|
+
throw new Error('AvatarRenderer has been disposed');
|
|
258
|
+
}
|
|
259
|
+
assertCurrent(request, kind) {
|
|
260
|
+
if (this.disposed || request !== (kind === 'model' ? this.modelRequest : this.environmentRequest)) {
|
|
261
|
+
throw new DOMException('Loading was superseded or renderer was disposed', 'AbortError');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
applyConfig() {
|
|
265
|
+
const config = this.config;
|
|
266
|
+
this.scene.environmentIntensity = config.intensity;
|
|
267
|
+
this.scene.backgroundIntensity = config.intensity;
|
|
268
|
+
this.scene.environmentRotation.y = THREE.MathUtils.degToRad(config.rotation);
|
|
269
|
+
this.scene.backgroundRotation.y = THREE.MathUtils.degToRad(config.rotation);
|
|
270
|
+
this.scene.background = config.panorama && this.environment ? this.environment : new THREE.Color(config.background);
|
|
271
|
+
this.renderer.toneMappingExposure = config.exposure;
|
|
272
|
+
this.renderer.toneMapping = config.toneMapping === 'aces' ? THREE.ACESFilmicToneMapping : THREE.NeutralToneMapping;
|
|
273
|
+
this.controls.autoRotate = config.autoRotate;
|
|
274
|
+
if (this.model) {
|
|
275
|
+
this.model.rotation.y = THREE.MathUtils.degToRad(config.modelRotation);
|
|
276
|
+
this.model.traverse((node) => {
|
|
277
|
+
const mesh = node;
|
|
278
|
+
if (!mesh.isMesh)
|
|
279
|
+
return;
|
|
280
|
+
for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
|
|
281
|
+
if ('wireframe' in material)
|
|
282
|
+
material.wireframe = config.wireframe;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
onContextLost = (event) => { event.preventDefault(); this.contextLost = true; this.syncLoop(); };
|
|
288
|
+
onContextRestored = () => {
|
|
289
|
+
if (this.disposed)
|
|
290
|
+
return;
|
|
291
|
+
// Render-target contents are lost with the context; rebuild filtered IBL.
|
|
292
|
+
if (this.environment) {
|
|
293
|
+
const pmrem = new THREE.PMREMGenerator(this.renderer);
|
|
294
|
+
try {
|
|
295
|
+
const target = pmrem.fromEquirectangular(this.environment);
|
|
296
|
+
this.scene.environment = target.texture;
|
|
297
|
+
this.environmentTarget?.dispose();
|
|
298
|
+
this.environmentTarget = target;
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
pmrem.dispose();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
this.contextLost = false;
|
|
305
|
+
this.syncLoop();
|
|
306
|
+
};
|
|
307
|
+
syncLoop = () => {
|
|
308
|
+
if (this.frame !== undefined)
|
|
309
|
+
cancelAnimationFrame(this.frame);
|
|
310
|
+
this.frame = undefined;
|
|
311
|
+
this.lastTime = 0;
|
|
312
|
+
if (!this.disposed && !document.hidden && !this.contextLost)
|
|
313
|
+
this.frame = requestAnimationFrame(this.render);
|
|
314
|
+
};
|
|
315
|
+
render = (time) => {
|
|
316
|
+
if (this.disposed)
|
|
317
|
+
return;
|
|
318
|
+
const delta = this.lastTime ? Math.min((time - this.lastTime) / 1000, 0.05) : 0;
|
|
319
|
+
this.lastTime = time;
|
|
320
|
+
this.controls.update(delta);
|
|
321
|
+
this.renderer.render(this.scene, this.camera);
|
|
322
|
+
this.frame = requestAnimationFrame(this.render);
|
|
323
|
+
};
|
|
324
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avatar-platform/avatar-render",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"assets",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"registry": "https://registry.npmjs.org/"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"prepack": "pnpm build"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"three": "^0.180.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"three": "^0.180.0",
|
|
35
|
+
"@types/three": "^0.180.0",
|
|
36
|
+
"typescript": "~5.6.2",
|
|
37
|
+
"vitest": "^2.1.9",
|
|
38
|
+
"jsdom": "^25.0.1"
|
|
39
|
+
}
|
|
40
|
+
}
|