mirror of
https://github.com/m5stack/StackChan.git
synced 2026-05-20 11:49:45 -07:00
503 lines
14 KiB
Dart
503 lines
14 KiB
Dart
/*
|
||||
|
|
SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
|||
|
|
SPDX-License-Identifier: MIT
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import 'dart:math';
|
|||
|
|
import 'dart:ui' as ui;
|
|||
|
|
|
|||
|
|
import 'package:flutter/cupertino.dart';
|
|||
|
|
import 'package:three_js/three_js.dart' as three;
|
|||
|
|
|
|||
|
|
import '../../model/dance_list.dart';
|
|||
|
|
import '../../model/expression_data.dart';
|
|||
|
|
|
|||
|
|
class StackChanRobotBox extends StatelessWidget {
|
|||
|
|
final DanceData data;
|
|||
|
|
final double width;
|
|||
|
|
final double height;
|
|||
|
|
final bool topLook;
|
|||
|
|
final bool allowsCameraControl;
|
|||
|
|
final bool mirrorFace;
|
|||
|
|
|
|||
|
|
const StackChanRobotBox({
|
|||
|
|
super.key,
|
|||
|
|
required this.width,
|
|||
|
|
required this.height,
|
|||
|
|
required this.data,
|
|||
|
|
this.topLook = false,
|
|||
|
|
this.allowsCameraControl = false,
|
|||
|
|
this.mirrorFace = false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return SizedBox(
|
|||
|
|
width: width,
|
|||
|
|
height: height,
|
|||
|
|
child: LayoutBuilder(
|
|||
|
|
builder: (context, constraints) {
|
|||
|
|
return StackchanRobotJs(
|
|||
|
|
width: constraints.maxWidth,
|
|||
|
|
height: constraints.maxHeight,
|
|||
|
|
data: data,
|
|||
|
|
topLook: topLook,
|
|||
|
|
allowsCameraControl: allowsCameraControl,
|
|||
|
|
mirrorFace: mirrorFace,
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class StackchanRobotJs extends StatefulWidget {
|
|||
|
|
const StackchanRobotJs({
|
|||
|
|
super.key,
|
|||
|
|
required this.data,
|
|||
|
|
required this.width,
|
|||
|
|
required this.height,
|
|||
|
|
required this.topLook,
|
|||
|
|
required this.allowsCameraControl,
|
|||
|
|
required this.mirrorFace,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
final DanceData data;
|
|||
|
|
final double width;
|
|||
|
|
final double height;
|
|||
|
|
final bool topLook;
|
|||
|
|
final bool allowsCameraControl;
|
|||
|
|
final bool mirrorFace;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
State<StatefulWidget> createState() => _StackchanRobotThreeState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _StackchanRobotThreeState extends State<StackchanRobotJs> {
|
|||
|
|
late three.ThreeJS threeJs;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
threeJs = three.ThreeJS(
|
|||
|
|
settings: three.Settings(
|
|||
|
|
alpha: true,
|
|||
|
|
clearAlpha: 0.0,
|
|||
|
|
clearColor: 0x000000,
|
|||
|
|
antialias: true,
|
|||
|
|
toneMapping: three.ReinhardToneMapping,
|
|||
|
|
toneMappingExposure: 1,
|
|||
|
|
),
|
|||
|
|
onSetupComplete: () {
|
|||
|
|
setState(() {});
|
|||
|
|
},
|
|||
|
|
setup: setup,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void dispose() {
|
|||
|
|
threeJs.dispose();
|
|||
|
|
super.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void didUpdateWidget(covariant StackchanRobotJs oldWidget) {
|
|||
|
|
super.didUpdateWidget(oldWidget);
|
|||
|
|
applyDanceData();
|
|||
|
|
if (oldWidget.topLook != widget.topLook) {
|
|||
|
|
setupCamera();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> setup() async {
|
|||
|
|
threeJs.scene = three.Scene();
|
|||
|
|
|
|||
|
|
//translated comment
|
|||
|
|
final hemiLight = three.HemisphereLight(0xffffff, 0x444444, 1);
|
|||
|
|
hemiLight.position.setValues(0, 100, 0);
|
|||
|
|
threeJs.scene.add(hemiLight);
|
|||
|
|
|
|||
|
|
//translated comment
|
|||
|
|
final dirLight = three.DirectionalLight(0xffffff, 1);
|
|||
|
|
dirLight.position.setValues(50, 50, 70);
|
|||
|
|
threeJs.scene.add(dirLight);
|
|||
|
|
|
|||
|
|
//andset
|
|||
|
|
threeJs.camera = three.PerspectiveCamera(
|
|||
|
|
60,
|
|||
|
|
widget.width / widget.height,
|
|||
|
|
1,
|
|||
|
|
300,
|
|||
|
|
);
|
|||
|
|
threeJs.camera.position.setValues(0, -100, 0);
|
|||
|
|
|
|||
|
|
//loadmodel
|
|||
|
|
three.GLTFLoader loader = three.GLTFLoader(flipY: true).setPath('assets/');
|
|||
|
|
final sky = await loader.fromAsset('stack_chan_model.glb');
|
|||
|
|
|
|||
|
|
if (sky == null || !mounted) return;
|
|||
|
|
|
|||
|
|
final model = sky.scene;
|
|||
|
|
|
|||
|
|
threeJs.scene.add(model);
|
|||
|
|
|
|||
|
|
setupCamera();
|
|||
|
|
|
|||
|
|
setupRobotHierarchy();
|
|||
|
|
|
|||
|
|
applyDanceData();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//setAngle
|
|||
|
|
void setupCamera() {
|
|||
|
|
if (widget.topLook) {
|
|||
|
|
threeJs.camera.position.setValues(0, -100, 70);
|
|||
|
|
} else {
|
|||
|
|
threeJs.camera.position.setValues(0, -100, 0);
|
|||
|
|
}
|
|||
|
|
threeJs.camera.lookAt(threeJs.scene.position);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
three.Object3D yawAxis = three.Object3D();
|
|||
|
|
three.Object3D pitchAxis = three.Object3D();
|
|||
|
|
|
|||
|
|
three.Mesh? expressionPlaneMesh; //faceshow
|
|||
|
|
three.CanvasTexture? expressionTexture; //facecanvastexture
|
|||
|
|
final double canvasWidth = 210; //canvas(correspondingiOS 42*5)
|
|||
|
|
final double canvasHeight = 160; //canvas(correspondingiOS 32*5)
|
|||
|
|
final String expressionPlaneName = "expressionPlane"; //name(foriOS)
|
|||
|
|
Function(double)? currentRotationEvent;
|
|||
|
|
|
|||
|
|
//translated comment
|
|||
|
|
void setupRobotHierarchy() {
|
|||
|
|
final model = threeJs.scene.children.firstWhere(
|
|||
|
|
(element) => element.type == "Group",
|
|||
|
|
);
|
|||
|
|
final foundation = model.getObjectByName('_00_stackchan450_3');
|
|||
|
|
final centralComponent = model.getObjectByName('_00_stackchan450_2');
|
|||
|
|
final head = model.getObjectByName('_00_stackchan450_1');
|
|||
|
|
|
|||
|
|
if (foundation == null || centralComponent == null || head == null) return;
|
|||
|
|
|
|||
|
|
//========== LeftRightto(yaw axis)logic(originalhaslogicCankeep,) ==========
|
|||
|
|
final centralWorldPos = centralComponent.worldPosition();
|
|||
|
|
centralWorldPos.y -= 20;
|
|||
|
|
yawAxis.setWorldPosition(centralWorldPos);
|
|||
|
|
foundation.add(yawAxis);
|
|||
|
|
final centralWorldTransform = centralComponent.worldTransform();
|
|||
|
|
final centralWorldPosition = centralComponent.worldPosition();
|
|||
|
|
yawAxis.add(centralComponent);
|
|||
|
|
centralComponent.setWorldTransform(centralWorldTransform);
|
|||
|
|
centralComponent.setWorldPosition(centralWorldPosition);
|
|||
|
|
|
|||
|
|
//========== UpDown(pitch axis)logic(corefixPart) ==========
|
|||
|
|
final headWorldPosition = head.worldPosition();
|
|||
|
|
final headWorldTransform = head.worldTransform();
|
|||
|
|
final pitchAxisWorldPosition = pitchAxis.worldPosition();
|
|||
|
|
pitchAxisWorldPosition.y -= 25;
|
|||
|
|
pitchAxis.setWorldPosition(pitchAxisWorldPosition);
|
|||
|
|
centralComponent.add(pitchAxis);
|
|||
|
|
pitchAxis.add(head);
|
|||
|
|
head.setWorldTransform(headWorldTransform);
|
|||
|
|
head.setWorldPosition(headWorldPosition);
|
|||
|
|
|
|||
|
|
addExpressionPlane();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void addExpressionPlane() {
|
|||
|
|
final model = threeJs.scene.children.firstWhere(
|
|||
|
|
(element) => element.type == "Group",
|
|||
|
|
);
|
|||
|
|
final head = model.getObjectByName('_00_stackchan450_1');
|
|||
|
|
if (head == null) return;
|
|||
|
|
|
|||
|
|
final geometry = three.PlaneGeometry(42, 32);
|
|||
|
|
expressionTexture = three.CanvasTexture();
|
|||
|
|
final material = three.MeshBasicMaterial({
|
|||
|
|
three.MaterialProperty.map: expressionTexture,
|
|||
|
|
three.MaterialProperty.transparent: false,
|
|||
|
|
three.MaterialProperty.side: three.DoubleSide,
|
|||
|
|
});
|
|||
|
|
expressionPlaneMesh = three.Mesh(geometry, material);
|
|||
|
|
expressionPlaneMesh!.name = "expressionPlane";
|
|||
|
|
expressionPlaneMesh!.position.setValues(0, 15.8, 0);
|
|||
|
|
expressionPlaneMesh!.rotation.x = -90 * pi / 180.0;
|
|||
|
|
expressionPlaneMesh!.rotation.z = pi;
|
|||
|
|
head.add(expressionPlaneMesh);
|
|||
|
|
material.needsUpdate = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//writedata
|
|||
|
|
void applyDanceData() {
|
|||
|
|
updateServos();
|
|||
|
|
updateExpression();
|
|||
|
|
updateRGBColor();
|
|||
|
|
setupContinuousRotation();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void updateServos() async {
|
|||
|
|
final data = widget.data;
|
|||
|
|
if (data.yawServo.rotate == 0) {
|
|||
|
|
double clampedYaw = data.yawServo.angle / 10.0;
|
|||
|
|
if (clampedYaw < -128) clampedYaw = -128;
|
|||
|
|
if (clampedYaw > 128) clampedYaw = 128;
|
|||
|
|
yawAxis.rotation.z = clampedYaw * pi / 180.0;
|
|||
|
|
}
|
|||
|
|
double clampedPitch = data.pitchServo.angle / 10.0;
|
|||
|
|
if (clampedPitch < 0) clampedPitch = 0;
|
|||
|
|
if (clampedPitch > 90) clampedPitch = 90;
|
|||
|
|
pitchAxis.rotation.x = -clampedPitch * pi / 180.0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void setupContinuousRotation() {
|
|||
|
|
final data = widget.data;
|
|||
|
|
|
|||
|
|
if (currentRotationEvent != null) {
|
|||
|
|
threeJs.events.remove(currentRotationEvent);
|
|||
|
|
currentRotationEvent = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (data.yawServo.rotate != 0) {
|
|||
|
|
double rotateSpeed = data.yawServo.rotate / 10.0;
|
|||
|
|
double radiansPerSecond = rotateSpeed * pi / 180.0;
|
|||
|
|
currentRotationEvent = (double dt) {
|
|||
|
|
yawAxis.rotation.z -= radiansPerSecond * dt;
|
|||
|
|
};
|
|||
|
|
threeJs.addAnimationEvent(currentRotationEvent!);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void updateRGBColor() {
|
|||
|
|
final threeColor = toThreeColor(widget.data.leftRgbColor);
|
|||
|
|
for (var node in threeJs.scene.children) {
|
|||
|
|
if (node is three.Mesh) {
|
|||
|
|
if (node.material != null) {
|
|||
|
|
if (node.material!.name == "MTL12") {
|
|||
|
|
if (node.material! is three.MeshStandardMaterial) {
|
|||
|
|
node.material!.emissive = threeColor;
|
|||
|
|
} else {
|
|||
|
|
node.material!.color = threeColor;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> updateExpression() async {
|
|||
|
|
final data = widget.data;
|
|||
|
|
if (expressionPlaneMesh == null || expressionTexture == null) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//1. createdrawExpressioncanvas
|
|||
|
|
final recorder = ui.PictureRecorder();
|
|||
|
|
final canvas = ui.Canvas(recorder);
|
|||
|
|
final paint = ui.Paint();
|
|||
|
|
|
|||
|
|
//fliphandle(Yaxisflip)
|
|||
|
|
if (widget.mirrorFace) {
|
|||
|
|
canvas.save();
|
|||
|
|
canvas.translate(canvasWidth, 0);
|
|||
|
|
canvas.scale(-1, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//background:With / Carry 70% transparency (0xB3 = 179/255)
|
|||
|
|
paint.color = const ui.Color(0xB3000000);
|
|||
|
|
canvas.drawRect(ui.Rect.fromLTWH(0, 0, canvasWidth, canvasHeight), paint);
|
|||
|
|
|
|||
|
|
final eyeSize = canvasWidth / 10;
|
|||
|
|
|
|||
|
|
//draweyefunction
|
|||
|
|
void drawEye(ExpressionItem item, ui.Offset centerOffset) {
|
|||
|
|
canvas.save();
|
|||
|
|
|
|||
|
|
//calculatesizescale
|
|||
|
|
final clampedSize = item.size.clamp(-100, 100);
|
|||
|
|
final sizeScale = clampedSize >= 0
|
|||
|
|
? 1.0 + clampedSize / 100.0
|
|||
|
|
: 1.0 + clampedSize / 200.0;
|
|||
|
|
|
|||
|
|
final scaledEyeSize = eyeSize * sizeScale;
|
|||
|
|
final visibleHeight = scaledEyeSize * (item.weight / 100);
|
|||
|
|
|
|||
|
|
//positionoffset
|
|||
|
|
final centerX = centerOffset.dx + item.x / 10 + eyeSize / 2;
|
|||
|
|
final centerY = centerOffset.dy + item.y / 10 + eyeSize / 2;
|
|||
|
|
|
|||
|
|
final eyeRect = ui.Rect.fromCenter(
|
|||
|
|
center: ui.Offset(centerX, centerY),
|
|||
|
|
width: scaledEyeSize,
|
|||
|
|
height: scaledEyeSize,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
//rotatehandle
|
|||
|
|
final rotationDegrees = item.rotation / 10.0;
|
|||
|
|
canvas.translate(centerX, centerY);
|
|||
|
|
canvas.rotate(rotationDegrees * pi / 180);
|
|||
|
|
canvas.translate(-centerX, -centerY);
|
|||
|
|
|
|||
|
|
//createcropeye
|
|||
|
|
final clipRect = ui.Rect.fromLTRB(
|
|||
|
|
eyeRect.left,
|
|||
|
|
eyeRect.bottom - visibleHeight,
|
|||
|
|
eyeRect.right,
|
|||
|
|
eyeRect.bottom,
|
|||
|
|
);
|
|||
|
|
canvas.clipRect(clipRect);
|
|||
|
|
|
|||
|
|
//draweye
|
|||
|
|
paint.color = const ui.Color(0xFFFFFFFF);
|
|||
|
|
canvas.drawOval(eyeRect, paint);
|
|||
|
|
|
|||
|
|
canvas.restore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//calculateeyeposition
|
|||
|
|
final eyeY = (canvasHeight * 0.4) - (eyeSize / 2);
|
|||
|
|
final leftEyePoint = ui.Offset((canvasWidth / 4) - (eyeSize / 2), eyeY);
|
|||
|
|
final rightEyePoint = ui.Offset(
|
|||
|
|
(canvasWidth / 4 * 3) - (eyeSize / 2),
|
|||
|
|
eyeY,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
drawEye(data.leftEye, leftEyePoint);
|
|||
|
|
drawEye(data.rightEye, rightEyePoint);
|
|||
|
|
|
|||
|
|
//2. drawmouth
|
|||
|
|
canvas.save();
|
|||
|
|
|
|||
|
|
final mouthWidth = (canvasWidth * 0.3 - data.mouth.weight / 10).toDouble();
|
|||
|
|
final mouthHeight = (3 + data.mouth.weight * 0.2).toDouble();
|
|||
|
|
final mouthX = ((canvasWidth - mouthWidth) / 2) + data.mouth.x / 10;
|
|||
|
|
final mouthY = (canvasHeight * 0.65) + data.mouth.y / 10;
|
|||
|
|
|
|||
|
|
final mouthCenter = ui.Offset(
|
|||
|
|
mouthX + mouthWidth / 2,
|
|||
|
|
mouthY + mouthHeight / 2,
|
|||
|
|
);
|
|||
|
|
final mRotation = data.mouth.rotation / 10.0;
|
|||
|
|
|
|||
|
|
canvas.translate(mouthCenter.dx, mouthCenter.dy);
|
|||
|
|
canvas.rotate(mRotation * pi / 180);
|
|||
|
|
canvas.translate(-mouthCenter.dx, -mouthCenter.dy);
|
|||
|
|
|
|||
|
|
final mouthRect = ui.Rect.fromLTWH(mouthX, mouthY, mouthWidth, mouthHeight);
|
|||
|
|
paint.color = const ui.Color(0xFFFFFFFF);
|
|||
|
|
|
|||
|
|
canvas.drawRRect(
|
|||
|
|
ui.RRect.fromRectAndRadius(
|
|||
|
|
mouthRect,
|
|||
|
|
ui.Radius.circular(mouthHeight / 2),
|
|||
|
|
),
|
|||
|
|
paint,
|
|||
|
|
);
|
|||
|
|
canvas.restore();
|
|||
|
|
|
|||
|
|
//resumecanvasstate(ifperformflip)
|
|||
|
|
if (widget.mirrorFace) {
|
|||
|
|
canvas.restore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//3. will Canvas convertastexturedata
|
|||
|
|
final picture = recorder.endRecording();
|
|||
|
|
final image = await picture.toImage(
|
|||
|
|
canvasWidth.toInt(),
|
|||
|
|
canvasHeight.toInt(),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (!mounted) {
|
|||
|
|
image.dispose();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
//[Core Fix]:use rawRgba And / WhileNotis png
|
|||
|
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
|||
|
|
|
|||
|
|
if (byteData != null) {
|
|||
|
|
//convertas three_js Uint8Array
|
|||
|
|
final uint8List = byteData.buffer.asUint8List();
|
|||
|
|
final nativeArray = three.Uint8Array.fromList(uint8List);
|
|||
|
|
|
|||
|
|
//updatetexture
|
|||
|
|
expressionTexture!.image = three.ImageElement(
|
|||
|
|
data: nativeArray,
|
|||
|
|
width: canvasWidth.toInt(),
|
|||
|
|
height: canvasHeight.toInt(),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
//marktextureNeedupdate
|
|||
|
|
expressionTexture!.needsUpdate = true;
|
|||
|
|
|
|||
|
|
//ifuse MeshBasicMaterial,EnsureWillrereadtexture
|
|||
|
|
if (expressionPlaneMesh!.material is three.Material) {
|
|||
|
|
(expressionPlaneMesh!.material as three.Material).needsUpdate = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
image.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return threeJs.build();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
three.Color toThreeColor(String rgbString) {
|
|||
|
|
String hex = rgbString.replaceFirst('#', '');
|
|||
|
|
if (hex.length == 6) {
|
|||
|
|
hex = 'FF$hex';
|
|||
|
|
} else if (hex.length != 8) {
|
|||
|
|
return three.Color(1, 1, 1);
|
|||
|
|
}
|
|||
|
|
final intValue = int.parse(hex, radix: 16);
|
|||
|
|
final int a = (intValue >> 24) & 0xFF;
|
|||
|
|
final int r = (intValue >> 16) & 0xFF;
|
|||
|
|
final int g = (intValue >> 8) & 0xFF;
|
|||
|
|
final int b = intValue & 0xFF;
|
|||
|
|
return three.Color(r / 255.0, g / 255.0, b / 255.0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
extension Object3DUtil on three.Object3D {
|
|||
|
|
three.Vector3 worldPosition() {
|
|||
|
|
final position = three.Vector3.zero();
|
|||
|
|
getWorldPosition(position);
|
|||
|
|
return position;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void setWorldPosition(three.Vector3 worldPosition) {
|
|||
|
|
if (parent != null) {
|
|||
|
|
parent!.updateWorldMatrix(true, false);
|
|||
|
|
final inverseParentMatrix = three.Matrix4()
|
|||
|
|
.setFrom(parent!.matrixWorld)
|
|||
|
|
.invert();
|
|||
|
|
final localPosition = worldPosition.clone().applyMatrix4(
|
|||
|
|
inverseParentMatrix,
|
|||
|
|
);
|
|||
|
|
position.setFrom(localPosition);
|
|||
|
|
} else {
|
|||
|
|
position.setFrom(worldPosition);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
three.Quaternion worldTransform() {
|
|||
|
|
final worldQuaternion = three.Quaternion();
|
|||
|
|
getWorldQuaternion(worldQuaternion);
|
|||
|
|
return worldQuaternion;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void setWorldTransform(three.Quaternion worldTransform) {
|
|||
|
|
if (parent != null) {
|
|||
|
|
parent!.updateWorldMatrix(true, false);
|
|||
|
|
final parentWorldQuaternion = three.Quaternion();
|
|||
|
|
parent!.getWorldQuaternion(parentWorldQuaternion);
|
|||
|
|
|
|||
|
|
final inverseParentQuaternion = parentWorldQuaternion.clone().conjugate();
|
|||
|
|
quaternion.setFrom(inverseParentQuaternion.multiply(worldTransform));
|
|||
|
|
} else {
|
|||
|
|
quaternion.setFrom(worldTransform);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|