Files
Sanctification/spikes/flutter-scene-spike/lib/main.dart
2026-09-08 14:00:27 -07:00

392 lines
12 KiB
Dart

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<CardSceneView> createState() => _CardSceneViewState();
}
class _CardSceneViewState extends State<CardSceneView> {
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<void> _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,
),
),
],
),
),
),
],
),
);
}
}