Files
slogan/lib/shared/widgets/avatar_viewer.dart
T
admin c4fd49bd7e feat(app): 穿搭生成 + 方案流 + 效果图 + 3D 化身查看
- outfit_provider:方案列表/生成任务轮询(pending→planning→scoring→rendering→done)/详情/选定主方案/收藏
- outfit_page:日期范围 + 地点生成表单,生成中显示任务进度,方案列表卡片
- plan_viewer_page:PageView 手指滑动切换方案,穿衣清单(衣橱/新品标记)、发型发色、设为主方案/收藏
- plan_effect_page:效果图 3 视角(正面/侧面/背面)查看
- avatar_viewer:three_dart + flutter_gl 3D 渲染实现(自动旋转),GLB 资产未接入时占位展示,kEnable3D 开关
- avatar_viewer_page:化身查看页 + 重新构建
- 新增 /plan-viewer /plan-effect /avatar-viewer 路由
2026-07-31 12:50:02 +08:00

243 lines
6.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gl/flutter_gl.dart';
import 'package:three_dart/three_dart.dart' as three;
import 'package:three_dart_jsm/three_dart_jsm.dart' as three_jsm;
import '../../core/config/app_config.dart';
/// 是否启用 three_dart 3D 渲染。
/// MVP 阶段服务端尚无预烘焙 GLB 资产(/workspace/templates/*.glb 为构建产物),
/// 默认关闭展示信息占位;接入真实 GLB 资源后置为 true 即可启用。
const bool kEnable3D = false;
/// 3D 化身查看组件:three_dart 渲染 GLB,加载失败/未启用时展示信息占位
class AvatarViewer extends StatelessWidget {
final String? glbUrl;
final String title;
final String subtitle;
final double height;
const AvatarViewer({
super.key,
required this.glbUrl,
required this.title,
required this.subtitle,
this.height = 360,
});
@override
Widget build(BuildContext context) {
if (!kEnable3D || glbUrl == null || glbUrl!.isEmpty) {
return _AvatarPlaceholder(
title: title, subtitle: subtitle, height: height);
}
return _ThreeDAvatarViewer(
url: AppConfig.resolveUrl(glbUrl!),
title: title,
subtitle: subtitle,
height: height,
);
}
}
/// three_dart 实现:flutter_gl 初始化 + GLTFLoader 加载 + 自动旋转渲染
class _ThreeDAvatarViewer extends StatefulWidget {
final String url;
final String title;
final String subtitle;
final double height;
const _ThreeDAvatarViewer({
required this.url,
required this.title,
required this.subtitle,
required this.height,
});
@override
State<_ThreeDAvatarViewer> createState() => _ThreeDAvatarViewerState();
}
class _ThreeDAvatarViewerState extends State<_ThreeDAvatarViewer> {
FlutterGlPlugin? _gl;
three.WebGLRenderer? _renderer;
three.Scene? _scene;
three.Camera? _camera;
three.Object3D? _model;
dynamic _sourceTexture;
double _width = 300;
double _dpr = 1;
bool _ready = false;
bool _failed = false;
bool _disposed = false;
Timer? _timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
}
@override
void dispose() {
_disposed = true;
_timer?.cancel();
super.dispose();
}
Future<void> _init() async {
final mq = MediaQuery.of(context);
_width = mq.size.width;
_dpr = mq.devicePixelRatio;
try {
final gl = FlutterGlPlugin();
await gl.initialize(options: {
'antialias': true,
'alpha': false,
'width': _width.toInt(),
'height': widget.height.toInt(),
'dpr': _dpr,
});
await gl.prepareContext();
final renderer = three.WebGLRenderer({
'width': _width,
'height': widget.height,
'gl': gl.gl,
'antialias': true,
'canvas': gl.element,
});
renderer.setPixelRatio(_dpr);
renderer.setSize(_width, widget.height, false);
final target = three.WebGLMultisampleRenderTarget(
(_width * _dpr).toInt(), (widget.height * _dpr).toInt(),
three.WebGLRenderTargetOptions({'format': three.RGBAFormat}));
target.samples = 4;
renderer.setRenderTarget(target);
final sourceTexture = renderer.getRenderTargetGLTexture(target);
await _loadModel(gl, renderer);
_gl = gl;
_renderer = renderer;
_sourceTexture = sourceTexture;
if (mounted) setState(() => _ready = true);
_timer = Timer.periodic(
const Duration(milliseconds: 33), (_) => _render());
} catch (e) {
debugPrint('3D 渲染不可用,回退占位:$e');
if (mounted) setState(() => _failed = true);
}
}
Future<void> _loadModel(
FlutterGlPlugin gl, three.WebGLRenderer renderer) async {
final scene = three.Scene();
final camera = three.PerspectiveCamera(45, _width / widget.height, 0.1, 100);
camera.position.set(0, 1.4, 3.2);
scene.add(three.AmbientLight(0xffffff, 0.9));
final keyLight = three.DirectionalLight(0xffffff, 0.9);
keyLight.position.set(2, 4, 3);
scene.add(keyLight);
scene.add(camera);
camera.lookAt(three.Vector3(0, 1, 0));
final loader = three_jsm.GLTFLoader(null);
final result = await loader.loadAsync(widget.url);
final model = result['scene'] as three.Object3D?;
if (model != null) {
model.rotation.y = 0.6;
scene.add(model);
}
_scene = scene;
_camera = camera;
_model = model;
}
void _render() {
if (_disposed || _renderer == null || _scene == null || _camera == null) {
return;
}
final model = _model;
if (model != null) {
model.rotation.y += 0.01; // 自动旋转
}
_renderer!.render(_scene!, _camera!);
_gl!.gl.flush();
if (!kIsWeb) {
_gl!.updateTexture(_sourceTexture);
}
}
@override
Widget build(BuildContext context) {
if (_failed) {
return _AvatarPlaceholder(
title: widget.title, subtitle: widget.subtitle, height: widget.height);
}
return SizedBox(
height: widget.height,
width: double.infinity,
child: _ready
? (kIsWeb
? HtmlElementView(viewType: _gl!.textureId!.toString())
: Texture(textureId: _gl!.textureId!))
: const Center(child: CircularProgressIndicator()),
);
}
}
/// 占位:GLB 资产未接入或加载失败时展示化身信息
class _AvatarPlaceholder extends StatelessWidget {
final String title;
final String subtitle;
final double height;
const _AvatarPlaceholder({
required this.title,
required this.subtitle,
required this.height,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
height: height,
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [scheme.primaryContainer, scheme.primary.withValues(alpha: 0.3)],
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.accessibility_new, size: 72, color: scheme.primary),
const SizedBox(height: 12),
Text(title,
style:
const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
subtitle.isEmpty ? '3D 渲染接入中(v2' : subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
);
}
}