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 _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 _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), ), ), ], ), ); } }