Fix project structure
This commit is contained in:
117
flutter-scene-spike/lib/card_geometry.dart
Normal file
117
flutter-scene-spike/lib/card_geometry.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_scene/scene.dart';
|
||||
import 'package:vector_math/vector_math.dart' as vm;
|
||||
|
||||
const cardWidth = 2.8571428571;
|
||||
const cardHeight = 4.0;
|
||||
const cardThickness = 0.0181405896;
|
||||
const cardCornerRadius = 0.1269841270;
|
||||
const _cornerSegments = 12;
|
||||
|
||||
List<vm.Vector2> _roundedPerimeter() {
|
||||
final points = <vm.Vector2>[];
|
||||
final halfWidth = cardWidth / 2;
|
||||
final halfHeight = cardHeight / 2;
|
||||
final centers = [
|
||||
vm.Vector2(halfWidth - cardCornerRadius, halfHeight - cardCornerRadius),
|
||||
vm.Vector2(-halfWidth + cardCornerRadius, halfHeight - cardCornerRadius),
|
||||
vm.Vector2(-halfWidth + cardCornerRadius, -halfHeight + cardCornerRadius),
|
||||
vm.Vector2(halfWidth - cardCornerRadius, -halfHeight + cardCornerRadius),
|
||||
];
|
||||
|
||||
for (var corner = 0; corner < centers.length; corner++) {
|
||||
final startAngle = corner * math.pi / 2;
|
||||
for (var step = 0; step <= _cornerSegments; step++) {
|
||||
final angle = startAngle + step / _cornerSegments * math.pi / 2;
|
||||
points.add(
|
||||
centers[corner] +
|
||||
vm.Vector2(math.cos(angle), math.sin(angle)) * cardCornerRadius,
|
||||
);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
MeshGeometry buildCardFaceGeometry({
|
||||
required bool front,
|
||||
}) {
|
||||
final perimeter = _roundedPerimeter();
|
||||
final z = front ? -cardThickness / 2 : cardThickness / 2;
|
||||
final positions = <double>[0, 0, z];
|
||||
final normals = <double>[0, 0, front ? -1 : 1];
|
||||
final texCoords = <double>[0.5, 0.5];
|
||||
|
||||
for (final point in perimeter) {
|
||||
positions.addAll([point.x, point.y, z]);
|
||||
normals.addAll([0, 0, front ? -1 : 1]);
|
||||
final u = front
|
||||
? point.x / cardWidth + 0.5
|
||||
: 0.5 - point.x / cardWidth;
|
||||
texCoords.addAll([u, 0.5 - point.y / cardHeight]);
|
||||
}
|
||||
|
||||
final indices = <int>[];
|
||||
for (var index = 0; index < perimeter.length; index++) {
|
||||
final current = index + 1;
|
||||
final next = (index + 1) % perimeter.length + 1;
|
||||
if (front) {
|
||||
indices.addAll([0, next, current]);
|
||||
} else {
|
||||
indices.addAll([0, current, next]);
|
||||
}
|
||||
}
|
||||
|
||||
return MeshGeometry.fromArrays(
|
||||
positions: Float32List.fromList(positions),
|
||||
normals: Float32List.fromList(normals),
|
||||
texCoords: Float32List.fromList(texCoords),
|
||||
indices: indices,
|
||||
);
|
||||
}
|
||||
|
||||
MeshGeometry buildCardEdgeGeometry() {
|
||||
final perimeter = _roundedPerimeter();
|
||||
final positions = <double>[];
|
||||
final normals = <double>[];
|
||||
final indices = <int>[];
|
||||
|
||||
for (var index = 0; index < perimeter.length; index++) {
|
||||
final current = perimeter[index];
|
||||
final next = perimeter[(index + 1) % perimeter.length];
|
||||
final normal = (current + next).normalized();
|
||||
final base = positions.length ~/ 3;
|
||||
positions.addAll([
|
||||
current.x,
|
||||
current.y,
|
||||
-cardThickness / 2,
|
||||
current.x,
|
||||
current.y,
|
||||
cardThickness / 2,
|
||||
next.x,
|
||||
next.y,
|
||||
cardThickness / 2,
|
||||
next.x,
|
||||
next.y,
|
||||
-cardThickness / 2,
|
||||
]);
|
||||
for (var vertex = 0; vertex < 4; vertex++) {
|
||||
normals.addAll([normal.x, normal.y, 0]);
|
||||
}
|
||||
indices.addAll([
|
||||
base,
|
||||
base + 2,
|
||||
base + 1,
|
||||
base,
|
||||
base + 3,
|
||||
base + 2,
|
||||
]);
|
||||
}
|
||||
|
||||
return MeshGeometry.fromArrays(
|
||||
positions: Float32List.fromList(positions),
|
||||
normals: Float32List.fromList(normals),
|
||||
indices: indices,
|
||||
);
|
||||
}
|
||||
391
flutter-scene-spike/lib/main.dart
Normal file
391
flutter-scene-spike/lib/main.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user