import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; import 'card_geometry.dart'; const spikeTitle = 'FLUTTER SCENE CARD SPIKE'; void main() { runApp(const SceneSpikeApp()); } class SceneSpikeApp extends StatelessWidget { const SceneSpikeApp({super.key}); @override Widget build(BuildContext context) { return const MaterialApp( debugShowCheckedModeBanner: false, home: CardSceneView(), ); } } class CardSceneView extends StatefulWidget { const CardSceneView({super.key}); @override State createState() => _CardSceneViewState(); } class _CardSceneViewState extends State { static const _frontPitch = -0.06; static const _frontYaw = 0.12; static const _minCameraDistance = 5.0; static const _maxCameraDistance = 12.0; final Scene scene = Scene(); final Node cardRoot = Node(name: 'CARD_ROOT'); final vm.Vector3 lightPosition = vm.Vector3(2.2, 2.5, 4.4); bool ready = false; String? error; double pitch = _frontPitch; double yaw = _frontYaw; double targetPitch = _frontPitch; double targetYaw = _frontYaw; double cameraDistance = 8.2; double defaultCameraDistance = 8.2; double gestureStartDistance = 8.2; Offset lastFocalPoint = Offset.zero; bool scriptedMotion = false; Duration latestElapsed = Duration.zero; double scriptStartSeconds = 0; double scriptStartPitch = _frontPitch; double scriptStartYaw = _frontYaw; @override void initState() { super.initState(); _initializeScene(); } @override void didChangeDependencies() { super.didChangeDependencies(); final size = MediaQuery.sizeOf(context); if (size.isEmpty) return; final verticalTangent = math.tan(34 * math.pi / 360); final verticalFit = cardHeight / 2 / verticalTangent; final horizontalFit = cardWidth / 2 / (verticalTangent * size.aspectRatio); final nextDefault = math.max(8.2, math.max(verticalFit, horizontalFit) * 1.08); if ((cameraDistance - defaultCameraDistance).abs() < 0.001) { cameraDistance = nextDefault; } defaultCameraDistance = nextDefault; } Future _initializeScene() async { try { debugPrint('Scene spike: initializing static resources'); await Scene.initializeStaticResources(); debugPrint('Scene spike: loading textures'); final artwork = await loadTexture('assets/david-front.png'); final finishMask = await loadTexture('assets/david-finish-mask.png'); final backArtwork = await loadTexture('assets/card-back.png'); debugPrint('Scene spike: loading materials'); final frontMaterial = await loadFmatMaterial( 'assets/card_holographic.fmat', ); final backMaterial = await loadFmatMaterial( 'assets/card_holographic.fmat', ); frontMaterial.parameters ..setTexture( 'artwork_texture', artwork.sampledTexture!, sampler: artwork.sampledSampler, ) ..setTexture( 'finish_mask_texture', finishMask.sampledTexture!, sampler: finishMask.sampledSampler, ) ..setVec3('light_position', lightPosition) ..setFloat('finish_strength', 0.6) ..setFloat('roughness', 0.23) ..setFloat('surface_detail', 0.14); backMaterial.parameters ..setTexture( 'artwork_texture', backArtwork.sampledTexture!, sampler: backArtwork.sampledSampler, ) ..setTexture( 'finish_mask_texture', finishMask.sampledTexture!, sampler: finishMask.sampledSampler, ) ..setVec3('light_position', lightPosition) ..setFloat('finish_strength', 0) ..setFloat('roughness', 0.42) ..setFloat('surface_detail', 0); final edgeMaterial = PhysicallyBasedMaterial() ..baseColorFactor = vm.Vector4(0.33, 0.27, 0.16, 1) ..roughnessFactor = 0.62 ..metallicFactor = 0.02; cardRoot.addAll([ Node( name: 'CARD_FRONT', mesh: Mesh(buildCardFaceGeometry(front: true), frontMaterial), ), Node( name: 'CARD_BACK', mesh: Mesh(buildCardFaceGeometry(front: false), backMaterial), ), Node( name: 'CARD_EDGE', mesh: Mesh(buildCardEdgeGeometry(), edgeMaterial), ), ]); scene.add(cardRoot); scene.add( Node( name: 'STUDIO_LIGHT', localTransform: vm.Matrix4.translation(lightPosition), )..addComponent( PointLightComponent( PointLight( color: vm.Vector3(1.0, 0.87, 0.63), intensity: 42, range: 20, falloffExponent: 1.7, ), ), ), ); scene.environmentSettings = EnvironmentSettings( toneMapping: ToneMappingMode.aces, exposure: 1, ); _applyCardRotation(); debugPrint('Scene spike: ready'); if (mounted) { setState(() { ready = true; }); } } catch (exception) { if (mounted) { setState(() { error = exception.toString(); }); } } } void _applyCardRotation() { cardRoot.rotation = vm.Quaternion.axisAngle(vm.Vector3(0, 1, 0), yaw) * vm.Quaternion.axisAngle(vm.Vector3(1, 0, 0), pitch); } void _setPose(double nextPitch, double nextYaw) { scriptedMotion = false; pitch = nextPitch; yaw = nextYaw; targetPitch = nextPitch; targetYaw = nextYaw; _applyCardRotation(); setState(() {}); } void _reset() { scriptedMotion = false; cameraDistance = defaultCameraDistance; _setPose(_frontPitch, _frontYaw); } void _flip() { scriptedMotion = false; targetPitch = pitch; targetYaw = yaw + math.pi; } void _toggleSweep(Duration elapsed) { if (scriptedMotion) { scriptedMotion = false; targetPitch = pitch; targetYaw = yaw; } else { scriptedMotion = true; scriptStartSeconds = elapsed.inMicroseconds / 1000000; scriptStartPitch = pitch; scriptStartYaw = yaw; } setState(() {}); } void _onScaleStart(ScaleStartDetails details) { scriptedMotion = false; lastFocalPoint = details.focalPoint; gestureStartDistance = cameraDistance; } void _onScaleUpdate(ScaleUpdateDetails details) { if (details.pointerCount >= 2) { cameraDistance = (gestureStartDistance / details.scale).clamp( _minCameraDistance, _maxCameraDistance, ); setState(() {}); return; } final delta = details.focalPoint - lastFocalPoint; yaw += delta.dx * 0.008; pitch = (pitch + delta.dy * 0.008).clamp(-1.25, 1.25); targetPitch = pitch; targetYaw = yaw; lastFocalPoint = details.focalPoint; _applyCardRotation(); } void _tick(Duration elapsed, double deltaSeconds) { latestElapsed = elapsed; final frameDelta = deltaSeconds.clamp(0, 0.1); if (scriptedMotion) { final seconds = elapsed.inMicroseconds / 1000000; final progress = ((seconds - scriptStartSeconds) / 5).clamp(0, 1); final eased = progress * progress * (3 - 2 * progress); final wave = math.sin(eased * math.pi); pitch = scriptStartPitch - 0.12 * wave; yaw = scriptStartYaw + math.pi / 7.5 * wave; if (progress >= 1) { scriptedMotion = false; targetPitch = scriptStartPitch; targetYaw = scriptStartYaw; } } else { final damping = 1 - math.exp(-9 * frameDelta); pitch += (targetPitch - pitch) * damping; yaw += (targetYaw - yaw) * damping; } _applyCardRotation(); } PerspectiveCamera _camera() { return PerspectiveCamera( position: vm.Vector3(0, 0, -cameraDistance), target: vm.Vector3.zero(), fovRadiansY: 34 * math.pi / 180, fovNear: 0.1, fovFar: 100, ); } Widget _controlButton(String label, VoidCallback onPressed) { return FilledButton.tonal( onPressed: onPressed, style: FilledButton.styleFrom( foregroundColor: const Color(0xFFF3EFE5), backgroundColor: const Color(0xFF171B23), side: const BorderSide(color: Color(0x554B5360)), ), child: Text(label), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF080A0E), body: Stack( children: [ if (ready) Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, child: SceneView( scene, cameraBuilder: (_) => _camera(), onTick: _tick, ), ), ) else Center( child: error == null ? const CircularProgressIndicator() : Padding( padding: const EdgeInsets.all(24), child: Text( error!, style: const TextStyle(color: Colors.redAccent), ), ), ), SafeArea( child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( spikeTitle, style: TextStyle( color: Color(0xFFD6C078), fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 1.4, ), ), const SizedBox(height: 3), const Text( 'David · Paper · Holographic', style: TextStyle( color: Color(0xFFF3EFE5), fontFamily: 'serif', fontSize: 20, ), ), const Spacer(), Wrap( spacing: 8, runSpacing: 8, children: [ _controlButton('Front', () { _setPose(_frontPitch, _frontYaw); }), _controlButton('Grazing', () { _setPose(-0.14, 52 * math.pi / 180); }), _controlButton('Edge', () { _setPose(-0.05, math.pi / 2); }), _controlButton('Back', () { _setPose(_frontPitch, math.pi + _frontYaw); }), _controlButton('Flip', _flip), _controlButton('Reset', _reset), _controlButton( scriptedMotion ? 'Stop sweep' : 'Play sweep', () => _toggleSweep(latestElapsed), ), ], ), const SizedBox(height: 8), const Text( 'Drag to rotate · pinch to zoom', style: TextStyle( color: Color(0xFF9DA3AD), fontSize: 12, ), ), ], ), ), ), ], ), ); } }