Merge remote-tracking branch 'origin/dev/2.0' into feat/gpu-instancing

This commit is contained in:
chenmo.gl
2026-05-19 21:05:24 +08:00
55 changed files with 1706 additions and 482 deletions

View File

@@ -113,11 +113,20 @@ animator.speed = 0;
animator.speed = 1;
```
If you only want to pause a specific `AnimatorState` , you can do so by setting its speed to 0.
If you only want to pause a specific `AnimatorState` on this Animator instance, set the per-instance speed to 0.
```typescript
const state = animator.findAnimatorState("xxx");
if (!state) {
// State not found in any layer
return;
}
// Pause only this Animator instance's playback of the state.
state.speed = 0;
// To resume later, write any non-zero value:
// state.speed = 1;
```
### Transition to Specified Animation State
@@ -130,26 +139,34 @@ animator.crossFade("OtherStateName", 0.3);
### Get Current Playing Animation State
You can use the [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) method to get the currently playing `AnimatorState` . The parameter is the index `layerIndex` of the `AnimatorControllerLayer` where the `AnimatorState` is located. For details, see the [API documentation](/apis/core/#Animator-getCurrentAnimatorState). After obtaining it, you can set the properties of the `AnimatorState` , such as changing the default loop playback to play once.
[getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) returns the playing `AnimatorStateInstance` on a given layer, or `null` when the layer is missing or nothing is playing.
```typescript
const currentState = animator.getCurrentAnimatorState(0);
// Play once
currentState.wrapMode = WrapMode.Once;
// Loop playback
currentState.wrapMode = WrapMode.Loop;
const current = animator.getCurrentAnimatorState(0);
if (current) {
current.speed = 0.5;
}
```
### Get Animation State
You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) method to get the specified `AnimatorState` . After obtaining it, you can set the properties of the `AnimatorState` , such as changing the default loop playback to play once.
[findAnimatorState](/apis/core/#Animator-findAnimatorState) returns the per-Animator `AnimatorStateInstance` (`AnimatorStateInstance | null`). Writes only affect this Animator; the shared `AnimatorState` on the controller is untouched. Same pattern as `Renderer.getInstanceMaterial`.
- `speed`, `wrapMode` are per-instance overrideable; unwritten reads return the shared default.
- `name`, `clip`, `clipStartTime`, `clipEndTime` read through to the shared asset.
To mutate the shared asset (broadcasts to every Animator), go through the controller path:
```typescript
const state = animator.findAnimatorState("xxx");
// Play once
if (!state) return;
// Per-instance overrides.
state.speed = 0.5;
state.wrapMode = WrapMode.Once;
// Loop playback
state.wrapMode = WrapMode.Loop;
// Broadcast to every Animator using this controller.
animator.animatorController.layers[0].stateMachine.findStateByName("xxx").clip = otherClip;
```
### Animation Culling

View File

@@ -117,11 +117,20 @@ animator.speed = 1;
```
如果你只想针对某一个 `动画状态` 进行暂停,可以通过将它的速度设为 0 来实现
如果你只想针对当前 `Animator` 上的某个 `动画状态` 进行暂停,将它的 per-instance 速度设为 0 即可
```typescript
const state = animator.findAnimatorState("xxx");
if (!state) {
// 任何一个动画层都没有该状态
return;
}
// 仅暂停当前 Animator 实例的该状态播放。
state.speed = 0;
// 想要恢复时写回任意非零速度即可:
// state.speed = 1;
```
### 过渡指定动画状态
@@ -135,26 +144,34 @@ animator.crossFade("OtherStateName", 0.3);
### 获取当前在播放的动画状态
你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。获取之后可以设置 `动画状态` 的属性,比如将默认的循环播放改为一次
[getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) 返回指定层当前播放的 `AnimatorStateInstance`,层不存在或未在播放时返回 `null`
```typescript
const currentState = animator.getCurrentAnimatorState(0);
// 播放一次
currentState.wrapMode = WrapMode.Once;
// 循环播放
currentState.wrapMode = WrapMode.Loop;
const current = animator.getCurrentAnimatorState(0);
if (current) {
current.speed = 0.5;
}
```
### 获取动画状态
你可以使用 [findAnimatorState](/apis/core/#Animator-findAnimatorState)  方法来获取指定名称的 `动画状态` 。获取之后可以设置动画状态的属性,比如将默认的循环播放改为一次
[findAnimatorState](/apis/core/#Animator-findAnimatorState) 返回当前 `Animator` 独有的 `AnimatorStateInstance``AnimatorStateInstance | null`)。对它的写入只影响当前 Animator共享 `AnimatorState` 资产不受影响。模式与 `Renderer.getInstanceMaterial` 一致
- `speed`、`wrapMode` 可逐实例覆盖;未写入前透传到共享默认值。
- `name` / `clip` / `clipStartTime` / `clipEndTime` 从共享资产转发。
要修改共享资产(广播到所有使用该控制器的 Animator通过控制器路径访问
```typescript
const state = animator.findAnimatorState("xxx");
// 播放一次
if (!state) return;
// 只影响当前 Animator
state.speed = 0.5;
state.wrapMode = WrapMode.Once;
// 循环播放
state.wrapMode = WrapMode.Loop;
// 广播到所有使用该控制器的 Animator
animator.animatorController.layers[0].stateMachine.findStateByName("xxx").clip = otherClip;
```
### 动画裁剪

View File

@@ -56,7 +56,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const additivePoseNames = animations.filter((clip) => clip.name.includes("pose")).map((clip) => clip.name);
additivePoseNames.forEach((name) => {
const clip = animator.findAnimatorState(name).clip;
const state = animator.findAnimatorState(name);
if (!state) {
throw new Error(`Animator state not found: ${name}`);
}
const clip = state.clip;
const newState = animatorStateMachine.addState(name);
newState.clipStartTime = 1;
newState.clip = clip;

View File

@@ -53,6 +53,9 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const animator = defaultSceneRoot.getComponent(Animator);
const state = animator.findAnimatorState("walk");
if (!state) {
throw new Error("Animator state not found: walk");
}
const clip = state.clip;
const event0 = new AnimationEvent();

View File

@@ -33,7 +33,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const { defaultSceneRoot } = gltfResource;
rootEntity.addChild(defaultSceneRoot);
const animator = defaultSceneRoot.getComponent(Animator);
animator.findAnimatorState("walk").speed = -1;
const walkState = animator.findAnimatorState("walk");
if (!walkState) {
throw new Error("Animator state not found: walk");
}
walkState.speed = -1;
animator.play("walk");
updateForE2E(engine);

View File

@@ -54,9 +54,12 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const animator = defaultSceneRoot.getComponent(Animator)!;
animator.animatorController.addParameter("playerSpeed", 1);
const stateMachine = animator.animatorController.layers[0].stateMachine;
const idleState = animator.findAnimatorState("idle");
const walkState = animator.findAnimatorState("walk");
const runState = animator.findAnimatorState("run");
const idleState = stateMachine.findStateByName("idle");
const walkState = stateMachine.findStateByName("walk");
const runState = stateMachine.findStateByName("run");
if (!idleState || !walkState || !runState) {
throw new Error("Required animator states not found: idle/walk/run");
}
let idleToWalkTime = 0;
let walkToRunTime = 0;
let runToWalkTime = 0;
@@ -70,7 +73,9 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
idleState.addTransition(toWalkTransition);
idleToWalkTime =
//@ts-ignore
toWalkTransition.exitTime * idleState._getDuration() + toWalkTransition.duration * walkState._getDuration();
toWalkTransition.exitTime * idleState._getDuration() +
//@ts-ignore
toWalkTransition.duration * walkState._getDuration();
const exitTransition = idleState.addExitTransition();
exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0);
@@ -97,16 +102,16 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
toIdleTransition.duration * idleState._getDuration();
// to run state
const RunToWalkTransition = new AnimatorStateTransition();
RunToWalkTransition.destinationState = walkState;
RunToWalkTransition.duration = 0.3;
RunToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5);
runState.addTransition(RunToWalkTransition);
const runToWalkTransition = new AnimatorStateTransition();
runToWalkTransition.destinationState = walkState;
runToWalkTransition.duration = 0.3;
runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5);
runState.addTransition(runToWalkTransition);
runToWalkTime =
//@ts-ignore
(RunToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() +
(runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() +
//@ts-ignore
RunToWalkTransition.duration * walkState._getDuration();
runToWalkTransition.duration * walkState._getDuration();
stateMachine.addEntryStateTransition(idleState);

View File

@@ -55,9 +55,12 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
rootEntity.addChild(defaultSceneRoot);
const animator = defaultSceneRoot.getComponent(Animator);
const state = animator.findAnimatorState("walk");
const walkState = animator.animatorController.layers[0].stateMachine.findStateByName("walk");
if (!walkState) {
throw new Error("Animator state not found: walk");
}
state.addStateMachineScript(
walkState.addStateMachineScript(
class extends StateMachineScript {
onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void {
textRenderer.text = "0";

View File

@@ -45,10 +45,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
rootEntity.addChild(defaultSceneRoot);
const animator = defaultSceneRoot.getComponent(Animator)!;
animator.play("Right");
const state = animator.getCurrentAnimatorState(0);
// clipStartTime/clipEndTime are on the shared AnimatorState asset, not the per-Animator instance view.
const state = animator.animatorController.layers[0].stateMachine.findStateByName("Right");
state.clipStartTime = 1;
state.clipEndTime = 1;
animator.play("Right");
updateForE2E(engine);

View File

@@ -1,7 +1,7 @@
{
"name": "@galacean/engine-e2e",
"private": true,
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"license": "MIT",
"scripts": {
"case": "vite serve .dev --config .dev/vite.config.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-examples",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"private": true,
"license": "MIT",
"main": "dist/main.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-root",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"packageManager": "pnpm@9.3.0",
"private": true,
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-core",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -105,6 +105,8 @@ export class Entity extends EngineObject {
/** @internal */
_scripts: DisorderedArray<Script> = new DisorderedArray<Script>();
/** @internal */
_scriptsVersion = 0;
/** @internal */
_children: Entity[] = [];
/** @internal */
_scene: Scene;
@@ -341,7 +343,7 @@ export class Entity extends EngineObject {
* @deprecated Please use `children` property instead.
* Find child entity by index.
* @param index - The index of the child entity
* @returns The component which be found
* @returns The entity that was found
*/
getChild(index: number): Entity {
return this._children[index];
@@ -349,8 +351,8 @@ export class Entity extends EngineObject {
/**
* Find entity by name.
* @param name - The name of the entity which want to be found
* @returns The component which be found
* @param name - The name of the entity to find
* @returns The entity that was found
*/
findByName(name: string): Entity {
if (name === this.name) {
@@ -369,14 +371,13 @@ export class Entity extends EngineObject {
/**
* Find the entity by path.
* @param path - The path of the entity eg: /entity
* @returns The component which be found
* @returns The entity that was found
*/
findByPath(path: string): Entity {
const splits = path.split("/").filter(Boolean);
if (!splits.length) {
return this;
}
return Entity._findChildByName(this, 0, splits, 0);
}
@@ -538,6 +539,7 @@ export class Entity extends EngineObject {
_addScript(script: Script) {
script._entityScriptsIndex = this._scripts.length;
this._scripts.add(script);
this._scriptsVersion++;
}
/**
@@ -547,6 +549,7 @@ export class Entity extends EngineObject {
const replaced = this._scripts.deleteByIndex(script._entityScriptsIndex);
replaced && (replaced._entityScriptsIndex = script._entityScriptsIndex);
script._entityScriptsIndex = -1;
this._scriptsVersion++;
}
/**

View File

@@ -5,7 +5,9 @@ import { Utils } from "./Utils";
* @internal
*/
export class UpdateFlagManager {
/** @internal */
/** Monotonic counter bumped on every `dispatch`; consumers can snapshot it for lazy pull-style cache invalidation. */
version = 0;
_updateFlags: UpdateFlag[] = [];
private _listeners: ((type?: number, param?: Object) => void)[] = [];
@@ -62,6 +64,8 @@ export class UpdateFlagManager {
* @param param - Event param
*/
dispatch(type?: number, param?: Object): void {
this.version++;
const updateFlags = this._updateFlags;
for (let i = updateFlags.length - 1; i >= 0; i--) {
updateFlags[i].dispatch(type, param);

View File

@@ -6,11 +6,11 @@ import { Renderer } from "../Renderer";
import { Script } from "../Script";
import { Logger } from "../base/Logger";
import { assignmentClone, ignoreClone } from "../clone/CloneManager";
import { ClearableObjectPool } from "../utils/ClearableObjectPool";
import { AnimatorController } from "./AnimatorController";
import { AnimatorControllerLayer } from "./AnimatorControllerLayer";
import { AnimatorControllerParameter, AnimatorControllerParameterValue } from "./AnimatorControllerParameter";
import { AnimatorState } from "./AnimatorState";
import { AnimatorStateInstance } from "./AnimatorStateInstance";
import { AnimatorStateTransition } from "./AnimatorStateTransition";
import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection";
import { KeyframeValueType } from "./Keyframe";
@@ -31,6 +31,7 @@ import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCur
*/
export class Animator extends Component {
private static _passedTriggerParameterNames = new Array<string>();
private static _tempScripts: Script[] = [];
/** Culling mode of this Animator. */
cullingMode: AnimatorCullingMode = AnimatorCullingMode.None;
@@ -55,8 +56,6 @@ export class Animator extends Component {
@ignoreClone
private _curveOwnerPool: Record<number, Record<string, AnimationCurveOwner<KeyframeValueType>>> = Object.create(null);
@ignoreClone
private _animationEventHandlerPool = new ClearableObjectPool(AnimationEventHandler);
@ignoreClone
private _parametersValueMap = <Record<string, AnimatorControllerParameterValue>>Object.create(null);
@ignoreClone
@@ -115,9 +114,7 @@ export class Animator extends Component {
* @param normalizedTimeOffset - The normalized time offset (between 0 and 1, default 0) to start the state's animation from
*/
play(stateName: string, layerIndex: number = -1, normalizedTimeOffset: number = 0): void {
if (this._controllerUpdateFlag?.flag) {
this._reset();
}
this._resetIfControllerUpdated();
const stateInfo = this._getAnimatorStateInfo(stateName, layerIndex);
const { state } = stateInfo;
@@ -192,9 +189,7 @@ export class Animator extends Component {
return;
}
if (this._controllerUpdateFlag?.flag) {
this._reset();
}
this._resetIfControllerUpdated();
this._updateMark++;
@@ -206,20 +201,29 @@ export class Animator extends Component {
}
/**
* Get the playing state from the target layerIndex.
* Get the state instance currently playing on the target layer.
* @param layerIndex - The layer index
* @returns The state instance, or null if nothing is playing
* @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one.
*/
getCurrentAnimatorState(layerIndex: number): AnimatorState {
return this._animatorLayersData[layerIndex]?.srcPlayData?.state;
getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null {
this._resetIfControllerUpdated();
return this._animatorLayersData[layerIndex]?.srcPlayData?.instance ?? null;
}
/**
* Get the state by name.
* Get the state instance for a named state on this Animator.
* Overrides on the returned instance only affect this Animator.
* @param stateName - The state name
* @param layerIndex - The layer index(default -1). If layer is -1, find the first state with the given state name
* @param layerIndex - The layer index (default -1, searches all layers)
* @returns The state instance, or null if no state matches
* @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one.
*/
findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorState {
return this._getAnimatorStateInfo(stateName, layerIndex).state;
findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null {
this._resetIfControllerUpdated();
const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex);
if (!state) return null;
return this._getAnimatorLayerData(foundLayer).getOrCreateInstance(state);
}
/**
@@ -324,13 +328,18 @@ export class Animator extends Component {
this._animatorLayersData.length = 0;
this._curveOwnerPool = Object.create(null);
this._parametersValueMap = Object.create(null);
this._animationEventHandlerPool.clear();
if (this._controllerUpdateFlag) {
this._controllerUpdateFlag.flag = false;
}
}
private _resetIfControllerUpdated(): void {
if (this._controllerUpdateFlag?.flag) {
this._reset();
}
}
/**
* @internal
*/
@@ -358,11 +367,12 @@ export class Animator extends Component {
normalizedTimeOffset: number,
isFixedDuration: boolean
): void {
if (this._controllerUpdateFlag?.flag) {
this._reset();
}
this._resetIfControllerUpdated();
const { state, layerIndex: playLayerIndex } = this._getAnimatorStateInfo(stateName, layerIndex);
if (!state) {
return;
}
const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex);
manuallyTransition.duration = duration;
@@ -388,8 +398,10 @@ export class Animator extends Component {
break;
}
}
} else {
} else if (layerIndex >= 0 && layerIndex < layers.length) {
state = layers[layerIndex].stateMachine.findStateByName(stateName);
} else {
layerIndex = -1;
}
}
stateInfo.layerIndex = layerIndex;
@@ -398,19 +410,18 @@ export class Animator extends Component {
}
private _getAnimatorStateData(
stateName: string,
animatorState: AnimatorState,
animatorLayerData: AnimatorLayerData,
layerIndex: number
): AnimatorStateData {
const { animatorStateDataMap } = animatorLayerData;
let animatorStateData = animatorStateDataMap[stateName];
let animatorStateData = animatorStateDataMap.get(animatorState);
if (!animatorStateData) {
animatorStateData = new AnimatorStateData();
animatorStateDataMap[stateName] = animatorStateData;
animatorStateData = new AnimatorStateData(animatorState);
animatorStateDataMap.set(animatorState, animatorStateData);
this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex);
this._saveAnimatorEventHandlers(animatorState, animatorStateData);
}
this._ensureEventHandlers(animatorState, animatorStateData);
return animatorStateData;
}
@@ -467,34 +478,41 @@ export class Animator extends Component {
}
}
private _saveAnimatorEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void {
const eventHandlerPool = this._animationEventHandlerPool;
const scripts = [];
private _ensureEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void {
// state._updateFlagManager dispatches on both clip-swap and clip-events-mutation,
// so its version covers every input that affects eventHandlers binding
const stateVersion = state._updateFlagManager.version;
const scriptsVersion = this._entity._scriptsVersion;
if (
animatorStateData.eventsBuiltVersion === stateVersion &&
animatorStateData.eventsBuiltScriptsVersion === scriptsVersion
) {
return;
}
const scripts = Animator._tempScripts;
this._entity.getComponents(Script, scripts);
const scriptCount = scripts.length;
const { events } = state.clip;
const { eventHandlers } = animatorStateData;
eventHandlers.length = 0;
for (let i = 0, n = events.length; i < n; i++) {
const event = events[i];
const eventHandler = new AnimationEventHandler();
const funcName = event.functionName;
const { handlers } = eventHandler;
const clipChangedListener = () => {
this._entity.getComponents(Script, scripts);
const scriptCount = scripts.length;
const { events } = state.clip;
eventHandlers.length = 0;
for (let i = 0, n = events.length; i < n; i++) {
const event = events[i];
const eventHandler = eventHandlerPool.get();
const funcName = event.functionName;
const { handlers } = eventHandler;
eventHandler.event = event;
handlers.length = 0;
for (let j = scriptCount - 1; j >= 0; j--) {
const script = scripts[j];
const handler = <Function>script[funcName]?.bind(script);
handler && handlers.push(handler);
}
eventHandlers.push(eventHandler);
eventHandler.event = event;
for (let j = scriptCount - 1; j >= 0; j--) {
const script = scripts[j];
const handler = <Function>script[funcName]?.bind(script);
handler && handlers.push(handler);
}
};
clipChangedListener();
state._updateFlagManager.addListener(clipChangedListener);
eventHandlers.push(eventHandler);
}
scripts.length = 0;
animatorStateData.eventsBuiltVersion = stateVersion;
animatorStateData.eventsBuiltScriptsVersion = scriptsVersion;
}
private _clearCrossData(animatorLayerData: AnimatorLayerData): void {
@@ -521,8 +539,8 @@ export class Animator extends Component {
}
private _prepareStandbyCrossFading(animatorLayerData: AnimatorLayerData): void {
// Standby have two sub state, one is never play, one is finished, never play srcPlayData.state is null
animatorLayerData.srcPlayData.state && this._prepareSrcCrossData(animatorLayerData, true);
// Standby have two sub state, one is never play (srcPlayData is null), one is finished (srcPlayData is non-null)
animatorLayerData.srcPlayData && this._prepareSrcCrossData(animatorLayerData, true);
// Add dest cross curve data
this._prepareDestCrossData(animatorLayerData, true);
}
@@ -614,9 +632,9 @@ export class Animator extends Component {
aniUpdate: boolean
): void {
const { srcPlayData } = layerData;
const { state } = srcPlayData;
const state = srcPlayData.instance._state;
const playSpeed = state.speed * this.speed;
const playSpeed = srcPlayData.instance.speed * this.speed;
const playDeltaTime = playSpeed * deltaTime;
srcPlayData.updateOrientation(playDeltaTime);
@@ -699,8 +717,9 @@ export class Animator extends Component {
);
if (transition) {
// Remove speed factor, use actual cost time
const remainDeltaTime = deltaTime - playCostTime / playSpeed;
// Remove speed factor, use actual cost time. Per-instance speed=0 means the source
// state is paused, so it consumes no time — pass deltaTime through to the destination.
const remainDeltaTime = playSpeed === 0 ? deltaTime : deltaTime - playCostTime / playSpeed;
remainDeltaTime > 0 && this._updateState(layerData, remainDeltaTime, aniUpdate);
}
}
@@ -711,7 +730,7 @@ export class Animator extends Component {
additive: boolean,
aniUpdate: boolean
): void {
const curveBindings = playData.state.clip._curveBindings;
const curveBindings = playData.instance.clip._curveBindings;
const finished = playData.playState === AnimatorStatePlayState.Finished;
if (aniUpdate || finished) {
@@ -745,20 +764,20 @@ export class Animator extends Component {
) {
const { srcPlayData, destPlayData, layerIndex } = layerData;
const { speed } = this;
const { state: srcState } = srcPlayData;
const { state: destState } = destPlayData;
const srcState = srcPlayData.instance._state;
const destState = destPlayData.instance._state;
const transitionDuration = layerData.crossFadeTransition._getFixedDuration();
if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) {
return;
}
const srcPlaySpeed = srcState.speed * speed;
const dstPlaySpeed = destState.speed * speed;
const srcPlaySpeed = srcPlayData.instance.speed * speed;
const dstPlaySpeed = destPlayData.instance.speed * speed;
const dstPlayDeltaTime = dstPlaySpeed * deltaTime;
srcPlayData && srcPlayData.updateOrientation(srcPlaySpeed * deltaTime);
destPlayData && destPlayData.updateOrientation(dstPlayDeltaTime);
srcPlayData.updateOrientation(srcPlaySpeed * deltaTime);
destPlayData.updateOrientation(dstPlayDeltaTime);
const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcPlayData;
const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destPlayData;
@@ -836,8 +855,8 @@ export class Animator extends Component {
aniUpdate: boolean
) {
const { crossLayerOwnerCollection } = layerData;
const { _curveBindings: srcCurves } = srcPlayData.state.clip;
const { state: destState } = destPlayData;
const { _curveBindings: srcCurves } = srcPlayData.instance.clip;
const destState = destPlayData.instance._state;
const { _curveBindings: destCurves } = destState.clip;
const finished = destPlayData.playState === AnimatorStatePlayState.Finished;
@@ -876,14 +895,14 @@ export class Animator extends Component {
aniUpdate: boolean
) {
const { destPlayData } = layerData;
const { state } = destPlayData;
const state = destPlayData.instance._state;
const transitionDuration = layerData.crossFadeTransition._getFixedDuration();
if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) {
return;
}
const playSpeed = state.speed * this.speed;
const playSpeed = destPlayData.instance.speed * this.speed;
const playDeltaTime = playSpeed * deltaTime;
destPlayData.updateOrientation(playDeltaTime);
@@ -950,7 +969,7 @@ export class Animator extends Component {
aniUpdate: boolean
) {
const { crossLayerOwnerCollection } = layerData;
const { state } = destPlayData;
const state = destPlayData.instance._state;
const { _curveBindings: curveBindings } = state.clip;
const { clipTime: destClipTime, playState } = destPlayData;
@@ -988,8 +1007,8 @@ export class Animator extends Component {
aniUpdate: boolean
): void {
const playData = layerData.srcPlayData;
const { state } = playData;
const actualSpeed = state.speed * this.speed;
const state = playData.instance._state;
const actualSpeed = playData.instance.speed * this.speed;
const actualDeltaTime = actualSpeed * deltaTime;
playData.updateOrientation(actualDeltaTime);
@@ -1030,7 +1049,7 @@ export class Animator extends Component {
}
const { curveLayerOwner } = playData.stateData;
const { _curveBindings: curveBindings } = playData.state.clip;
const { _curveBindings: curveBindings } = playData.instance.clip;
for (let i = curveBindings.length - 1; i >= 0; i--) {
const layerOwner = curveLayerOwner[i];
@@ -1051,14 +1070,13 @@ export class Animator extends Component {
} else {
layerData.layerState = LayerState.Playing;
}
layerData.switchPlayData();
layerData.crossFadeTransition = null;
layerData.completeCrossFade();
}
private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void {
if (layerData.layerState === LayerState.Playing) {
const srcPlayData = layerData.srcPlayData;
if (srcPlayData.state !== playState) {
if (srcPlayData.instance._state !== playState) {
const { curveLayerOwner } = srcPlayData.stateData;
for (let i = curveLayerOwner.length - 1; i >= 0; i--) {
curveLayerOwner[i]?.curveOwner.revertDefaultValue();
@@ -1082,7 +1100,7 @@ export class Animator extends Component {
deltaTime: number,
aniUpdate: boolean
): AnimatorStateTransition {
const { state } = playData;
const state = playData.instance._state;
const clipDuration = state.clip.length;
let targetTransition: AnimatorStateTransition = null;
const startTime = state.clipStartTime * clipDuration;
@@ -1299,19 +1317,23 @@ export class Animator extends Component {
}
private _preparePlay(state: AnimatorState, layerIndex: number, normalizedTimeOffset: number = 0): boolean {
const name = state.name;
if (!state.clip) {
Logger.warn(`The state named ${name} has no AnimationClip data.`);
Logger.warn(`The state named ${state.name} has no AnimationClip data.`);
return false;
}
const animatorLayerData = this._getAnimatorLayerData(layerIndex);
const animatorStateData = this._getAnimatorStateData(name, state, animatorLayerData, layerIndex);
const animatorStateData = this._getAnimatorStateData(state, animatorLayerData, layerIndex);
this._preparePlayOwner(animatorLayerData, state);
animatorLayerData.layerState = LayerState.Playing;
animatorLayerData.srcPlayData.reset(state, animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset);
const playData = animatorLayerData.getOrCreateInstance(state)._playData;
playData.reset(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset);
animatorLayerData.srcPlayData = playData;
// Drop any dangling cross-fade slot from a previously-interrupted crossFade
// so a later crossFade(B) isn't wrongly no-op'd by the active-dest guard.
animatorLayerData.clearCrossFadeSlot();
animatorLayerData.resetCurrentCheckIndex();
return true;
@@ -1411,13 +1433,21 @@ export class Animator extends Component {
}
const animatorLayerData = this._getAnimatorLayerData(layerIndex);
const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex);
animatorLayerData.destPlayData.reset(
crossState,
animatorStateData,
transition.offset * crossState._getClipActualEndTime()
);
// Self/active-dest cross-fade is a no-op: each state has one persistent
// instance per layer, so a second concurrent fade has nowhere to live.
if (
animatorLayerData.srcPlayData?.instance._state === crossState ||
animatorLayerData.destPlayData?.instance._state === crossState
) {
return false;
}
const animatorStateData = this._getAnimatorStateData(crossState, animatorLayerData, layerIndex);
const destPlayData = animatorLayerData.getOrCreateInstance(crossState)._playData;
destPlayData.reset(animatorStateData, transition.offset * crossState._getClipActualEndTime());
animatorLayerData.destPlayData = destPlayData;
animatorLayerData.resetCurrentCheckIndex();
switch (animatorLayerData.layerState) {
@@ -1452,7 +1482,8 @@ export class Animator extends Component {
lastClipTime: number,
deltaTime: number
): void {
const { state, isForward, clipTime } = playData;
const { isForward, clipTime } = playData;
const state = playData.instance._state;
const startTime = state._getClipActualStartTime();
const endTime = state._getClipActualEndTime();
@@ -1564,6 +1595,10 @@ export class Animator extends Component {
lastPlayState: AnimatorStatePlayState,
deltaTime: number
) {
// Re-check whether the clip events/scripts changed since the last build —
// play()/crossFade() entry points already ensure on enter, but addEvent()
// or addComponent(Script) after play() must also flow through.
this._ensureEventHandlers(state, playData.stateData);
const { eventHandlers } = playData.stateData;
eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime);

View File

@@ -0,0 +1,76 @@
import { AnimationClip } from "./AnimationClip";
import { AnimatorState } from "./AnimatorState";
import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData";
import { WrapMode } from "./enums/WrapMode";
/**
* Per-Animator view of an `AnimatorState`.
*
* Override fields (speed, wrapMode) are scoped to this Animator; unset fields
* fall through to the underlying state asset.
*/
export class AnimatorStateInstance {
/** @internal */
readonly _state: AnimatorState;
/** @internal */
readonly _playData: AnimatorStatePlayData;
private _speed: number | undefined;
private _wrapMode: WrapMode | undefined;
/**
* The name of the underlying state.
*/
get name(): string {
return this._state.name;
}
/**
* The animation clip of the underlying state.
*/
get clip(): AnimationClip {
return this._state.clip;
}
/**
* The normalized clip start time of the underlying state.
*/
get clipStartTime(): number {
return this._state.clipStartTime;
}
/**
* The normalized clip end time of the underlying state.
*/
get clipEndTime(): number {
return this._state.clipEndTime;
}
/**
* Playback speed for this Animator; overrides the underlying state when set.
*/
get speed(): number {
return this._speed ?? this._state.speed;
}
set speed(value: number) {
this._speed = value;
}
/**
* Wrap mode for this Animator; overrides the underlying state when set.
*/
get wrapMode(): WrapMode {
return this._wrapMode ?? this._state.wrapMode;
}
set wrapMode(value: WrapMode) {
this._wrapMode = value;
}
/** @internal */
constructor(state: AnimatorState) {
this._state = state;
this._playData = new AnimatorStatePlayData(this);
}
}

View File

@@ -17,9 +17,9 @@ export class AnimatorStateMachine {
/**
* The state will be played automatically.
* @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered.
* @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. Cleared to `null` if the state is removed via `removeState`.
*/
defaultState: AnimatorState;
defaultState: AnimatorState | null = null;
/** @internal */
_entryTransitionCollection = new AnimatorStateTransitionCollection();
@@ -68,8 +68,11 @@ export class AnimatorStateMachine {
const index = this.states.indexOf(state);
if (index > -1) {
this.states.splice(index, 1);
delete this._statesMap[name];
if (this.defaultState === state) {
this.defaultState = null;
}
}
delete this._statesMap[name];
}
/**

View File

@@ -11,6 +11,7 @@ export { Animator } from "./Animator";
export { AnimatorController } from "./AnimatorController";
export { AnimatorControllerLayer } from "./AnimatorControllerLayer";
export { AnimatorState } from "./AnimatorState";
export { AnimatorStateInstance } from "./AnimatorStateInstance";
export { AnimatorStateMachine } from "./AnimatorStateMachine";
export { AnimatorStateTransition } from "./AnimatorStateTransition";
export { AnimatorConditionMode } from "./enums/AnimatorConditionMode";

View File

@@ -1,11 +1,9 @@
import { IPoolElement } from "../../utils/ObjectPool";
import { AnimationEvent } from "../AnimationEvent";
/**
* @internal
*/
export class AnimationEventHandler implements IPoolElement {
export class AnimationEventHandler {
event: AnimationEvent;
handlers: Function[] = [];
dispose() {}
}

View File

@@ -1,9 +1,11 @@
import { AnimatorControllerLayer } from "../AnimatorControllerLayer";
import { AnimatorState } from "../AnimatorState";
import { AnimatorStateInstance } from "../AnimatorStateInstance";
import { AnimatorStateTransition } from "../AnimatorStateTransition";
import { LayerState } from "../enums/LayerState";
import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner";
import { AnimatorStateData } from "./AnimatorStateData";
import { AnimatorStatePlayData } from "./AnimatorStatePlayData";
import type { AnimatorStatePlayData } from "./AnimatorStatePlayData";
/**
* @internal
@@ -12,20 +14,35 @@ export class AnimatorLayerData {
layerIndex: number;
layer: AnimatorControllerLayer;
curveOwnerPool: Record<number, Record<string, AnimationCurveLayerOwner>> = Object.create(null);
animatorStateDataMap: Record<string, AnimatorStateData> = {};
srcPlayData: AnimatorStatePlayData = new AnimatorStatePlayData();
destPlayData: AnimatorStatePlayData = new AnimatorStatePlayData();
animatorStateDataMap: WeakMap<AnimatorState, AnimatorStateData> = new WeakMap();
instanceMap: WeakMap<AnimatorState, AnimatorStateInstance> = new WeakMap();
srcPlayData: AnimatorStatePlayData | null = null;
destPlayData: AnimatorStatePlayData | null = null;
layerState: LayerState = LayerState.Standby;
crossCurveMark: number = 0;
manuallyTransition: AnimatorStateTransition = new AnimatorStateTransition();
crossFadeTransition: AnimatorStateTransition;
crossLayerOwnerCollection: AnimationCurveLayerOwner[] = [];
switchPlayData(): void {
const srcPlayData = this.destPlayData;
const switchTemp = this.srcPlayData;
this.srcPlayData = srcPlayData;
this.destPlayData = switchTemp;
getOrCreateInstance(state: AnimatorState): AnimatorStateInstance {
const map = this.instanceMap;
let instance = map.get(state);
if (!instance) {
instance = new AnimatorStateInstance(state);
map.set(state, instance);
}
return instance;
}
completeCrossFade(): void {
this.srcPlayData = this.destPlayData;
this.destPlayData = null;
this.crossFadeTransition = null;
}
clearCrossFadeSlot(): void {
this.destPlayData = null;
this.crossFadeTransition = null;
}
resetCurrentCheckIndex(): void {

View File

@@ -1,3 +1,4 @@
import { AnimatorState } from "../AnimatorState";
import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner";
import { AnimationEventHandler } from "./AnimationEventHandler";
@@ -7,4 +8,8 @@ import { AnimationEventHandler } from "./AnimationEventHandler";
export class AnimatorStateData {
curveLayerOwner: AnimationCurveLayerOwner[] = [];
eventHandlers: AnimationEventHandler[] = [];
eventsBuiltVersion = -1;
eventsBuiltScriptsVersion = -1;
constructor(readonly state: AnimatorState) {}
}

View File

@@ -1,4 +1,4 @@
import { AnimatorState } from "../AnimatorState";
import { AnimatorStateInstance } from "../AnimatorStateInstance";
import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState";
import { WrapMode } from "../enums/WrapMode";
import { AnimatorStateData } from "./AnimatorStateData";
@@ -7,27 +7,30 @@ import { AnimatorStateData } from "./AnimatorStateData";
* @internal
*/
export class AnimatorStatePlayData {
state: AnimatorState;
stateData: AnimatorStateData;
playedTime: number;
playState: AnimatorStatePlayState;
clipTime: number;
currentEventIndex: number;
isForward = true;
offsetFrameTime: number;
private _changedOrientation = false;
playedTime: number = 0;
playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted;
clipTime: number = 0;
currentEventIndex: number = 0;
isForward: boolean = true;
offsetFrameTime: number = 0;
reset(state: AnimatorState, stateData: AnimatorStateData, offsetFrameTime: number): void {
this.state = state;
this.playedTime = 0;
this.offsetFrameTime = offsetFrameTime;
private _changedOrientation: boolean = false;
constructor(public readonly instance: AnimatorStateInstance) {}
reset(stateData: AnimatorStateData, offsetFrameTime: number): void {
const state = this.instance._state;
this.stateData = stateData;
this.offsetFrameTime = offsetFrameTime;
this.playedTime = 0;
this.playState = AnimatorStatePlayState.UnStarted;
this.clipTime = state.clipStartTime * state.clip.length;
this.currentEventIndex = 0;
this.isForward = true;
this.state._transitionCollection.needResetCurrentCheckIndex = true;
this._changedOrientation = false;
state._transitionCollection.needResetCurrentCheckIndex = true;
}
updateOrientation(deltaTime: number): void {
@@ -43,11 +46,12 @@ export class AnimatorStatePlayData {
update(deltaTime: number): void {
this.playedTime += deltaTime;
const state = this.state;
const instance = this.instance;
const state = instance._state;
let time = this.playedTime + this.offsetFrameTime;
const duration = state._getDuration();
this.playState = AnimatorStatePlayState.Playing;
if (state.wrapMode === WrapMode.Loop) {
if (instance.wrapMode === WrapMode.Loop) {
time = duration ? time % duration : 0;
} else {
if (Math.abs(time) >= duration) {
@@ -65,8 +69,9 @@ export class AnimatorStatePlayData {
}
}
private _correctTime() {
const { state } = this;
private _correctTime(): void {
const state = this.instance._state;
// Reverse playback at clipTime=0 would step into negatives; jump to clipEnd.
if (this.clipTime === 0) {
this.clipTime = state.clipEndTime * state.clip.length;
}

View File

@@ -44,7 +44,7 @@ export enum AssetType {
Font = "Font",
/** Source Font, include ttf, otf and woff. */
SourceFont = "SourceFont",
/** AudioClip, include ogg, wav and mp3. */
/** AudioClip, include ogg, wav, mp3, m4a, aac and flac. */
Audio = "Audio",
/** Project asset. */
Project = "project",

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-design",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-loader",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -9,7 +9,7 @@ import {
ResourceManager,
resourceLoader
} from "@galacean/engine-core";
@resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav"])
@resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav", "m4a", "aac", "flac"])
class AudioLoader extends Loader<AudioClip> {
load(item: LoadItem, resourceManager: ResourceManager): AssetPromise<AudioClip> {
return new AssetPromise((resolve, reject) => {

View File

@@ -10,8 +10,6 @@ import {
@resourceLoader(AssetType.Shader, ["shader", "shaderc"])
class ShaderLoader extends Loader<Shader> {
private static _builtinRegex = /^\s*\/\/\s*@builtin\s+(\w+)/;
load(item: LoadItem, resourceManager: ResourceManager): AssetPromise<Shader> {
const url = item.url!;
@@ -25,17 +23,7 @@ class ShaderLoader extends Loader<Shader> {
// @ts-ignore
return resourceManager._request<string>(url, { ...item, type: "text" }).then((code: string) => {
const builtinShader = this._getBuiltinShader(code);
if (builtinShader) {
return Shader.find(builtinShader);
}
return Shader.create(code, undefined, url);
});
}
private _getBuiltinShader(code: string) {
const match = code.match(ShaderLoader._builtinRegex);
if (match && match[1]) return match[1];
}
}

View File

@@ -116,13 +116,13 @@ export class GLTFParserContext {
return AssetPromise.all([
this.get<void>(GLTFParserType.Validator),
this.get<Entity>(GLTFParserType.Scene),
this.get<Texture2D>(GLTFParserType.Texture),
this.get<Material>(GLTFParserType.Material),
this.get<ModelMesh[]>(GLTFParserType.Mesh),
this.get<Skin>(GLTFParserType.Skin),
this.get<AnimationClip>(GLTFParserType.Animation),
this.get<AnimatorController>(GLTFParserType.AnimatorController),
this.get<Entity>(GLTFParserType.Scene)
this.get<AnimatorController>(GLTFParserType.AnimatorController)
]).then(() => {
const glTFResource = this.glTFResource;
const animatorController = glTFResource.animatorController;

View File

@@ -34,17 +34,20 @@ export class GLTFSkinParser extends GLTFParser {
}
skin.bones = bones;
// Get skeleton
// Get skeleton — when `skin.skeleton` is absent, resolve via joints' LCA
// LCA falls back to the GLTF_ROOT wrapper only when joints span multiple top-level scene nodes
if (skeleton !== undefined) {
const rootBone = entities[skeleton];
if (!rootBone) {
throw `Skin skeleton index ${skeleton} is out of range.`;
}
skin.rootBone = rootBone;
} else {
const rootBone = this._findSkeletonRootBone(joints, entities);
if (rootBone) {
skin.rootBone = rootBone;
} else {
if (!rootBone) {
throw "Failed to find skeleton root bone.";
}
skin.rootBone = rootBone;
}
return skin;
@@ -53,7 +56,11 @@ export class GLTFSkinParser extends GLTFParser {
return AssetPromise.resolve(skinPromise);
}
private _findSkeletonRootBone(joints: number[], entities: Entity[]): Entity {
/**
* Resolve the skeleton rootBone as the lowest common ancestor of the joints' parent chains.
* Returns null when joints share no common ancestor.
*/
private _findSkeletonRootBone(joints: number[], entities: Entity[]): Entity | null {
const paths = <Record<number, Entity[]>>{};
for (const index of joints) {
const path = new Array<Entity>();
@@ -65,7 +72,7 @@ export class GLTFSkinParser extends GLTFParser {
paths[index] = path;
}
let rootNode = <Entity>null;
let rootNode: Entity | null = null;
for (let i = 0; ; i++) {
let path = paths[joints[0]];
if (i >= path.length) {

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-math",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-physics-lite",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-physics-physx",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-rhi-webgl",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"repository": {
"url": "https://github.com/galacean/engine.git"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-shader-compiler",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -30,37 +30,50 @@ export class ParserUtils {
}
/**
* Return the lexeme of `expr` when it is (transitively) a single bare identifier
* wrapped in primary/postfix expression nodes, e.g. `o` → "o". Returns null for
* compound expressions (`o.x`, `foo(..)`, `arr[0]`, …). Useful for callers that
* want to apply a substitution rule only at the root of an expression, never on
* swizzles or nested member access.
* Walk single-child precedence-chain wrappers down to a `VariableIdentifier`,
* returning the leaf node (or `undefined` for any compound expression).
*
* `allowParens` chooses between two callers' needs:
* - `true` — descend through `( expression )` form, so `(v)` resolves to
* `v`. Use when the caller substitutes at the expression root
* (aliasing, renaming); user-written parens carry no extra meaning.
* - `false` — any 3-child `PrimaryExpression` aborts; `(v)` stays
* compound. Use when the caller treats the unwrapped node as a single
* token (IO-struct arg drop, alias detection); user-written parens
* must not collapse.
*/
static unwrapBareIdentifier(
node: TreeNode,
options: { allowParens: boolean }
): ASTNode.VariableIdentifier | undefined {
let cur: TreeNode = node;
while (true) {
if (cur instanceof ASTNode.VariableIdentifier) return cur;
if (options.allowParens && cur instanceof ASTNode.PrimaryExpression && cur.children.length === 3) {
const inner = cur.children[1];
if (!(inner instanceof TreeNode)) return undefined;
cur = inner;
continue;
}
if (cur instanceof ASTNode.ExpressionAstNode && cur.children.length === 1) {
const child = cur.children[0];
if (!(child instanceof TreeNode)) return undefined;
cur = child;
continue;
}
return undefined;
}
}
/**
* Lexeme variant of `unwrapBareIdentifier({ allowParens: true })` for callers
* that already work with strings. Returns `null` for compound expressions.
*/
static extractDirectIdentLexeme(expr: TreeNode): string | null {
let cur: TreeNode | Token = expr;
while (cur) {
if (cur instanceof ASTNode.VariableIdentifier) {
const child = cur.children[0];
return child instanceof Token ? child.lexeme : null;
}
// `( expression )` form on PrimaryExpression — descend through the
// wrapped Expression so `(v)` resolves to `v`. All other compound
// forms (member access, function call, comma, …) bail out.
if (cur instanceof ASTNode.PrimaryExpression && cur.children.length === 3) {
cur = cur.children[1];
continue;
}
// Any precedence-chain wrapper with a single child forwards through.
// Covers PostfixExpression / PrimaryExpression(len=1) / Assignment /
// Conditional / LogicalOr / … all the way down — including the verbose
// build's full chain and the release build's elision-flattened chain.
if (cur instanceof ASTNode.ExpressionAstNode && cur.children.length === 1) {
cur = cur.children[0];
continue;
}
return null;
}
return null;
const ident = ParserUtils.unwrapBareIdentifier(expr, { allowParens: true });
if (!ident) return null;
const child = ident.children[0];
return child instanceof Token ? child.lexeme : null;
}
// #if _VERBOSE

View File

@@ -27,11 +27,14 @@ export class ShaderCompilerUtils {
source: string,
location: ShaderRange | ShaderPosition,
file?: string
): Error | undefined {
): Error {
// #if _VERBOSE
return new GSError(errorName, message, location, source, file);
// #else
console.error(message);
const err = new Error(message);
err.name = errorName;
return err;
// #endif
}
}

View File

@@ -147,39 +147,26 @@ export abstract class CodeGenVisitor {
const astNodes = paramList.paramNodes;
const context = VisitorContext.context;
// `MacroCallFunction` covers two call shapes sharing the same AST:
// (a) object-like macro whose value is a function name, used as a call —
// `#define FN foo` + `FN(varyings, …)`. The driver expands `FN` to
// `foo`, and `foo` is a shader function whose IO-struct params
// have been flattened. The call site must drop IO-struct args to
// match the flattened signature — same rule as `visitFunctionCall`.
// (b) true function-like macro — `#define MAX3(a,b,c) …` + `MAX3(v.x, …)`.
// The shader compiler doesn't expand the macro; the driver does, and the
// `#define` fixes the parameter count. Args must be preserved
// verbatim — a member-access arg like `v.v_uv` unwraps to a root
// identifier whose type is an IO struct, but dropping the arg
// would change the macro's arity.
//
// `isFunctionLikeMacro` is set by `MacroCallSymbol.semanticAnalyze` from
// `macroDefineList[name][*].isFunction` and carries the definition shape.
const params = node.isFunctionLikeMacro
? astNodes
: astNodes.filter((arg) => {
if (arg instanceof ASTNode.AssignmentExpression) {
const variableParam = ParserUtils.unwrapNodeByType<ASTNode.VariableIdentifier>(
arg,
NoneTerminal.variable_identifier
);
if (
variableParam &&
typeof variableParam.typeInfo === "string" &&
context.getStructRole(variableParam.typeInfo)
) {
return false;
}
// Drop bare IO-struct args only when the macro aliases a user fn (whose
// formal was flattened). All other shapes preserve args verbatim.
let params: typeof astNodes;
if (node.isFunctionLikeMacro || !node.aliasesNonBuiltinIdent) {
params = astNodes;
} else {
params = astNodes.filter((arg) => {
if (arg instanceof ASTNode.AssignmentExpression) {
const variableParam = ParserUtils.unwrapBareIdentifier(arg, { allowParens: false });
if (
variableParam &&
typeof variableParam.typeInfo === "string" &&
context.getStructRole(variableParam.typeInfo)
) {
return false;
}
return true;
});
}
return true;
});
}
let paramsCode = "";
for (let i = 0, length = params.length; i < length; i++) {

View File

@@ -1,3 +1,4 @@
import { Logger } from "@galacean/engine-core";
import { IBaseSymbol } from "./IBaseSymbol";
export class SymbolTable<T extends IBaseSymbol> {
@@ -10,7 +11,7 @@ export class SymbolTable<T extends IBaseSymbol> {
for (let i = 0, n = entry.length; i < n; i++) {
if (entry[i].isInMacroBranch) continue;
if (entry[i].equal(symbol)) {
console.warn("Replace symbol:", symbol.ident);
Logger.warn("Replace symbol:", symbol.ident);
entry[i] = symbol;
return;
}

View File

@@ -1,7 +1,9 @@
import { Logger } from "@galacean/engine-core";
import { ETokenType } from "../common";
import { Keyword } from "../common/enums/Keyword";
import { Grammar } from "../parser/Grammar";
import { GrammarSymbol, NoneTerminal, Terminal } from "../parser/GrammarSymbol";
import Production from "./Production";
import State from "./State";
import StateItem from "./StateItem";
import { default as GrammarUtils, default as Utils } from "./Utils";
@@ -157,12 +159,17 @@ export class LALR1 {
private _addAction(table: ActionTable, terminal: Terminal, action: ActionInfo) {
const exist = table.get(terminal);
if (exist && !Utils.isActionEqual(exist, action)) {
// Resolve dangling else ambiguity
if (terminal === Keyword.ELSE && exist.action === EAction.Shift && action.action === EAction.Reduce) {
return;
// Known shift-preferred conflicts (see TargetParser.y `%expect 2`).
// Enforce shift regardless of the order `_inferNextState` registers
// actions: when `exist` is already Shift and `action` is Reduce, keep
// `exist` (early-return); the reverse order (`exist` Reduce, `action`
// Shift) falls through to `table.set` below and Shift overwrites
// Reduce. Order-independent by construction.
if (LALR1._isKnownShiftPreferred(terminal, exist, action)) {
if (exist.action === EAction.Shift && action.action === EAction.Reduce) return;
} else {
// #if _VERBOSE
console.warn(
Logger.warn(
`conflict detect: <Terminal ${GrammarUtils.toString(terminal)}> \n`,
Utils.printAction(exist),
"\n",
@@ -174,6 +181,28 @@ export class LALR1 {
table.set(terminal, action);
}
// Catalog of expected shift/reduce conflicts. Each entry must correspond to
// one of TargetParser.y's `%expect`-ed conflicts; any new conflict not in
// this list falls through to the verbose `conflict detect` warning so the
// grammar/runtime drift is loud rather than silent.
// - ELSE: dangling-else, bind to nearest `if`
// - '(' + `type_specifier_nonarray → macro_call_symbol`: macro-as-type-alias
// (#2974), prefer the expression-position `macro_call_function` over the
// type-position reduce
private static _isKnownShiftPreferred(terminal: Terminal, exist: ActionInfo, action: ActionInfo): boolean {
if (terminal === Keyword.ELSE) return true;
if (terminal !== ETokenType.LEFT_PAREN) return false;
const reduce = exist.action === EAction.Reduce ? exist : action.action === EAction.Reduce ? action : null;
if (!reduce) return false;
const prod = Production.pool.get(reduce.target!);
return (
!!prod &&
prod.goal === NoneTerminal.type_specifier_nonarray &&
prod.derivation.length === 1 &&
prod.derivation[0] === NoneTerminal.macro_call_symbol
);
}
// https://people.cs.pitt.edu/~jmisurda/teaching/cs1622/handouts/cs1622-first_and_follow.pdf
private computeFirstSet() {
for (const production of this.grammar.productions.slice(1)) {

View File

@@ -1392,36 +1392,14 @@ export namespace ASTNode {
const child = this.children[0] as BaseToken | MacroCallSymbol | MacroCallFunction;
const referenceGlobalSymbolNames = this.referenceGlobalSymbolNames;
const symbols = this._symbols;
const lookupSymbol = SemanticAnalyzer._lookupSymbol;
let needFindNames: string[];
// FXAA-style cross-arm shadowing: same name is a macro in one
// preprocessor arm and a variable in the mutually-exclusive arm.
// At a MACRO_CALL use site, also probe the macro name itself so
// the sibling-arm declaration is marked as referenced and codegen
// keeps it. Grammar half of the fix is in 87cb2b5f0.
let macroNameAsVarLookup: string | null = null;
if (child instanceof BaseToken) {
needFindNames = [child.lexeme];
} else {
const callSymbol = child as MacroCallSymbol | MacroCallFunction;
needFindNames = callSymbol.referenceSymbolNames;
const macroName = callSymbol.macroName;
if (macroName && needFindNames.indexOf(macroName) === -1) {
needFindNames = needFindNames.concat(macroName);
macroNameAsVarLookup = macroName;
}
}
// Real references — every name must resolve; miss is an authoring error.
const needFindNames = child instanceof BaseToken ? [child.lexeme] : child.referenceSymbolNames;
for (let i = 0; i < needFindNames.length; i++) {
const name = needFindNames[i];
// `macroDefineList` short-circuit; bypass for the macro name itself
// so cross-arm shadowing can resolve the sibling-arm declaration.
if (sa.macroDefineList[name] && name !== macroNameAsVarLookup) {
continue;
}
if (sa.macroDefineList[name]) continue;
// only `macro_call` CFG can reference fnSymbols, others fnSymbols are referenced in `function_call_generic` CFG
if (!(child instanceof BaseToken) && BuiltinFunction.isExist(name)) {
@@ -1434,37 +1412,86 @@ export namespace ASTNode {
continue;
}
lookupSymbol.set(name, ESymbolType.Any);
sa.symbolTableStack.lookupAll(lookupSymbol, true, symbols);
if (!symbols.length) {
// #if _VERBOSE
sa.reportWarning(this.location, `Please sure the identifier "${name}" will be declared before used.`);
// #endif
} else {
// Expression-style macros have their own value AST; its real type isn't
// the type of any single `referenceSymbolNames` entry (`v` in `v.v_uv`
// is a `Varyings` struct but the macro call site's type should be the
// member type). Skip type inference for those and keep TypeAny.
if (child instanceof BaseToken || !child.hasAstValue) {
this.typeInfo = symbols[0].dataType?.type;
}
const currentScopeSymbol = <VarSymbol | FnSymbol>sa.symbolTableStack.scope.getSymbol(lookupSymbol, true);
if (currentScopeSymbol) {
if (
(currentScopeSymbol instanceof FnSymbol || currentScopeSymbol.isGlobalVariable) &&
referenceGlobalSymbolNames.indexOf(name) === -1
) {
referenceGlobalSymbolNames.push(name);
}
} else if (
symbols.some((s) => s instanceof FnSymbol || s.isGlobalVariable) &&
referenceGlobalSymbolNames.indexOf(name) === -1
) {
referenceGlobalSymbolNames.push(name);
}
const hit = VariableIdentifier._lookupAndMarkGlobalReference(
sa,
name,
symbols,
referenceGlobalSymbolNames,
this.location
);
// Expression-style macros have their own value AST; its real type isn't
// the type of any single `referenceSymbolNames` entry (`v` in `v.v_uv`
// is a `Varyings` struct but the macro call site's type should be the
// member type). Skip type inference for those and keep TypeAny.
if (hit && (child instanceof BaseToken || !child.hasAstValue)) {
this.typeInfo = symbols[0].dataType?.type;
}
}
// FXAA-style cross-arm shadowing: at a MACRO_CALL use site, silently
// probe the macro name itself so any sibling-arm `var` declaration is
// marked as referenced and codegen keeps it. Miss is the common
// single-arm case — no warning, no type inference. Grammar half of the
// cross-arm fix is in 87cb2b5f0.
if (!(child instanceof BaseToken)) {
VariableIdentifier._probeCrossArmShadowing(sa, child, needFindNames, symbols, referenceGlobalSymbolNames);
}
}
/** Run the cross-arm shadowing probe for a MACRO_CALL site. No-op when the
* probe isn't meaningful: no macro name, name already resolved as a real
* reference, or name is a builtin (builtins can't be shadowed by a
* sibling-arm decl). */
private static _probeCrossArmShadowing(
sa: SemanticAnalyzer,
child: MacroCallSymbol | MacroCallFunction,
needFindNames: string[],
symbols: (VarSymbol | FnSymbol)[],
referenceGlobalSymbolNames: string[]
): void {
const macroName = child.macroName;
if (!macroName) return;
if (needFindNames.indexOf(macroName) !== -1) return; // already looked up as a real reference
if (BuiltinFunction.isExist(macroName) || BuiltinVariable.getVar(macroName)) return; // builtins can't be shadowed
VariableIdentifier._lookupAndMarkGlobalReference(sa, macroName, symbols, referenceGlobalSymbolNames, null);
}
/** Look up `name` in the symbol stack and, if a global var/fn declaration
* exists, push it into `referenceGlobalSymbolNames`. Returns `true` iff
* the lookup hit (caller can then derive type info). When `missWarnLoc`
* is non-null, a miss reports a "declared before used" warning; pass
* `null` for silent probes (e.g. FXAA-style cross-arm shadowing).
*
* Mutation contract: `symbols` is used as scratch storage — `lookupAll`
* clears and refills it. On hit, the caller may read `symbols[0]` for
* type info before the next call overwrites the contents. */
private static _lookupAndMarkGlobalReference(
sa: SemanticAnalyzer,
name: string,
symbols: (VarSymbol | FnSymbol)[],
referenceGlobalSymbolNames: string[],
missWarnLoc: ShaderRange | null
): boolean {
const lookupSymbol = SemanticAnalyzer._lookupSymbol;
lookupSymbol.set(name, ESymbolType.Any);
sa.symbolTableStack.lookupAll(lookupSymbol, true, symbols);
if (!symbols.length) {
// #if _VERBOSE
if (missWarnLoc) {
sa.reportWarning(missWarnLoc, `Please sure the identifier "${name}" will be declared before used.`);
}
// #endif
return false;
}
const currentScopeSymbol = <VarSymbol | FnSymbol>sa.symbolTableStack.scope.getSymbol(lookupSymbol, true);
const isGlobal = currentScopeSymbol
? currentScopeSymbol instanceof FnSymbol || currentScopeSymbol.isGlobalVariable
: symbols.some((s) => s instanceof FnSymbol || s.isGlobalVariable);
if (isGlobal && referenceGlobalSymbolNames.indexOf(name) === -1) {
referenceGlobalSymbolNames.push(name);
}
return true;
}
override codeGen(visitor: CodeGenVisitor): string {
@@ -1661,15 +1688,16 @@ export namespace ASTNode {
* a root of `referenceSymbolNames`. Mixed forms across branches → false,
* fall back to legacy inference. */
hasAstValue: boolean = false;
/** True when the macro is defined as function-like (`#define NAME(params) …`).
* Used by `MacroCallFunction` codegen to pick between the two call shapes
* — object-like-macro-as-function-name vs true function-like macro. */
/** `#define NAME(params) …` form — drives function-like vs object-like codegen. */
isFunctionLikeMacro: boolean = false;
/** Every visible replacement is a non-builtin identifier — assume user fn alias. */
aliasesNonBuiltinIdent: boolean = false;
override init(): void {
this.referenceSymbolNames.length = 0;
this.hasAstValue = false;
this.isFunctionLikeMacro = false;
this.aliasesNonBuiltinIdent = false;
}
override semanticAnalyze(sa: SemanticAnalyzer): void {
@@ -1689,6 +1717,7 @@ export namespace ASTNode {
let visibleCount = 0;
let allAst = true;
let isFn = false;
let allAliasNonBuiltinIdent = true;
if (defList) {
for (let i = 0, n = defList.length; i < n; i++) {
const info = defList[i];
@@ -1702,6 +1731,22 @@ export namespace ASTNode {
if (info.valueAst) {
MacroCallSymbol._collectIdentifierRefs(info.valueAst, info.params, refs);
}
// aliasesNonBuiltinIdent: macro replacement is a single non-builtin
// identifier — best-effort proxy for "this macro call site aliases a
// user fn", since uniform/const aliases would surface as a GLSL
// compile error later anyway. Keyword replacements (`vec3`, `mat4`)
// reach here with `valueAst === undefined` (opaque path), so they
// automatically fail without an explicit keyword guard.
if (info.isFunction || !info.valueAst) {
allAliasNonBuiltinIdent = false;
} else {
const leadingIdent = ParserUtils.unwrapBareIdentifier(info.valueAst, { allowParens: false });
const leadingChild = leadingIdent?.children[0];
const leadingId = leadingChild instanceof BaseToken ? leadingChild.lexeme : undefined;
if (!leadingId || BuiltinFunction.isExist(leadingId)) {
allAliasNonBuiltinIdent = false;
}
}
}
}
// Require *every* visible entry to be AST-form before taking the AST
@@ -1710,6 +1755,7 @@ export namespace ASTNode {
// instead of polluting the call site with TypeAny.
this.hasAstValue = visibleCount > 0 && allAst;
this.isFunctionLikeMacro = isFn;
this.aliasesNonBuiltinIdent = visibleCount > 0 && allAliasNonBuiltinIdent;
}
/** Push every leaf `VariableIdentifier`'s lexeme into `out`, skipping
@@ -1739,12 +1785,14 @@ export namespace ASTNode {
macroName: string = "";
hasAstValue: boolean = false;
isFunctionLikeMacro: boolean = false;
aliasesNonBuiltinIdent: boolean = false;
override init(): void {
this.referenceSymbolNames = [];
this.macroName = "";
this.hasAstValue = false;
this.isFunctionLikeMacro = false;
this.aliasesNonBuiltinIdent = false;
}
override semanticAnalyze(sa: SemanticAnalyzer): void {
@@ -1754,6 +1802,7 @@ export namespace ASTNode {
this.macroName = child.macroName;
this.hasAstValue = child.hasAstValue;
this.isFunctionLikeMacro = child.isFunctionLikeMacro;
this.aliasesNonBuiltinIdent = child.aliasesNonBuiltinIdent;
}
override codeGen(visitor: CodeGenVisitor) {
@@ -1839,6 +1888,14 @@ export namespace ASTNode {
if (entries) entries.push(info);
else list[this.macroName] = [info];
}
// Close the form-param scope pushed at `MACRO_DEFINE_PARAMS` shift. By
// the time this reduce fires, every `VariableIdentifier` inside the
// value expression has already resolved against the params. Object-like
// macros never pushed a scope, so nothing to pop.
if (this.isFunction) {
sa.popScope();
}
}
override codeGen(visitor: CodeGenVisitor): string {

View File

@@ -1,3 +1,4 @@
import { Logger } from "@galacean/engine-core";
import { ShaderRange } from "../common";
import { SymbolTable } from "../common/SymbolTable";
import { SymbolTableStack } from "../common/SymbolTableStack";
@@ -88,6 +89,6 @@ export default class SemanticAnalyzer {
}
reportWarning(loc: ShaderRange, message: string): void {
console.warn(new GSError(GSErrorName.CompilationWarn, message, loc, ShaderCompiler._processingPassText).toString());
Logger.warn(new GSError(GSErrorName.CompilationWarn, message, loc, ShaderCompiler._processingPassText).toString());
}
}

View File

@@ -1,5 +1,6 @@
import { ETokenType } from "../common";
import { BaseToken } from "../common/BaseToken";
import { Keyword } from "../common/enums/Keyword";
import { GSError, GSErrorName } from "../GSError";
import { LALR1 } from "../lalr";
import { addTranslationRule, createGrammar } from "../lalr/CFG";
@@ -12,6 +13,7 @@ import { ASTNode, TreeNode } from "./AST";
import { Grammar } from "./Grammar";
import { GrammarSymbol, NoneTerminal } from "./GrammarSymbol";
import SematicAnalyzer from "./SemanticAnalyzer";
import { ESymbolType, SymbolInfo } from "./symbolTable";
import { TraceStackItem } from "./types";
/**
@@ -74,6 +76,18 @@ export class ShaderTargetParser {
const actionInfo = this.stateActionTable.get(token.type);
if (actionInfo?.action === EAction.Shift) {
traceBackStack.push(token, actionInfo.target!);
// Function-like `#define` form params live in a scope wrapping the
// value AST, mirroring how `function_header` opens a scope for GLSL
// function parameters. Push on shift of `MACRO_DEFINE_PARAMS`; the
// matching `popScope` runs when `MacroDefine.semanticAnalyze` reduces
// the production (only the function-like alternative needs it, and
// it knows that from its own children).
if (token.type === Keyword.MACRO_DEFINE_PARAMS) {
sematicAnalyzer.pushScope();
for (const p of ParserUtils.parseMacroParamList(token.lexeme)) {
sematicAnalyzer.symbolTableStack.insert(new SymbolInfo(p, ESymbolType.VAR));
}
}
nextToken = tokens.next();
} else if (actionInfo?.action === EAction.Accept) {
sematicAnalyzer.acceptRule?.(sematicAnalyzer);

View File

@@ -1,4 +1,28 @@
// For cfg conflict test, used by bison
// Bison-format mirror of the runtime grammar in `lalr/CFG.ts`.
//
// This file is consumed *only* by `bison -r all TargetParser.y` to produce a
// human-readable LALR(1) state report — it does NOT generate runtime parser
// code. The runtime grammar lives in `lalr/CFG.ts` and is the source of truth.
//
// Keep this file in sync with `CFG.ts` so the bison conflict report reflects
// the actual state machine the runtime LALR1 builder produces. Naming
// differences are tolerated (e.g. `eq` here ↔ `EQ_OP` in CFG.ts), but the
// *set* of productions per non-terminal and the symbol *structure* of each
// alternative must match.
// Two expected shift/reduce conflicts — bison errors if the count diverges,
// catching new unintended ambiguity at grammar-change time:
// 1. `ELSE` in `selection_statement` — classic dangling-else, shift binds
// the `else` to the nearest `if`. Standard C/GLSL resolution.
// 2. `(` in `type_specifier_nonarray → macro_call_symbol` (since #2974) —
// at a `macro_call_symbol .` item with `(` lookahead, shift forms a
// `macro_call_function` (expression-position macro call) rather than
// reducing to `type_specifier_nonarray`. Matches the AST node identity
// design intent of #2974.
// Both are handled deterministically by `LALR1._isKnownShiftPreferred`
// (lalr/LALR1.ts) — runtime resolution is order-independent, not reliant on
// bison's implicit shift-wins default.
%expect 2
%token id
%token INT_CONSTANT
@@ -8,11 +32,48 @@
%token void
%token float
%token bool
%token int
%token uint
%token vec2
%token vec3
%token vec4
%token bvec2
%token bvec3
%token bvec4
%token ivec2
%token ivec3
%token ivec4
%token uvec2
%token uvec3
%token uvec4
%token mat2
%token mat3
%token mat4
%token mat2x3
%token mat2x4
%token mat3x2
%token mat3x4
%token mat4x2
%token mat4x3
%token sampler2D
%token sampler3D
%token samplerCube
%token sampler2DShadow
%token samplerCubeShadow
%token sampler2DArray
%token sampler2DArrayShadow
%token isampler2D
%token isampler3D
%token isamplerCube
%token isampler2DArray
%token usampler2D
%token usampler3D
%token usamplerCube
%token usampler2DArray
%token struct
%token highp
%token mediemp
%token mediump
%token lowp
%token const
@@ -168,8 +229,45 @@ precision_specifier:
ext_builtin_type_specifier_nonarray:
void
| float
| int
| bool
| int
| uint
| vec2
| vec3
| vec4
| bvec2
| bvec3
| bvec4
| ivec2
| ivec3
| ivec4
| uvec2
| uvec3
| uvec4
| mat2
| mat3
| mat4
| mat2x3
| mat2x4
| mat3x2
| mat3x4
| mat4x2
| mat4x3
| sampler2D
| sampler3D
| samplerCube
| sampler2DShadow
| samplerCubeShadow
| sampler2DArray
| sampler2DArrayShadow
| isampler2D
| isampler3D
| isamplerCube
| isampler2DArray
| usampler2D
| usampler3D
| usamplerCube
| usampler2DArray
;
type_specifier_nonarray:
@@ -185,8 +283,8 @@ type_specifier_nonarray:
;
struct_specifier:
struct id '{' struct_declaration_list '}' ;
| struct '{' struct_declaration_list '}' ;
struct id '{' struct_declaration_list '}' ';'
| struct '{' struct_declaration_list '}' ';'
;
struct_declaration_list:
@@ -216,7 +314,7 @@ macro_struct_branch:
layout_qualifier:
layout '(' location '=' INT_CONSTANT ')'
| layout '(' location '=' id ')'
;
struct_declarator_list:
@@ -241,7 +339,7 @@ type_specifier:
precision_qualifier:
highp
| mediemp
| mediump
| lowp
;
@@ -421,7 +519,11 @@ function_call:
function_call_generic:
function_identifier '(' function_call_parameter_list ')'
| function_identifier '(' ')'
| function_identifier '(' void ')'
// Mirrors CFG.ts:681 verbatim. This alt is unreachable from any legal
// GLSL token stream (lexer never produces `f VOID )` without `(` between)
// — present since the LALR refactor (#2113, 2024-07). Tracked as latent
// bug; fix belongs in a separate PR with a `f(void)` regression test.
| function_identifier void ')'
;
function_call_parameter_list:
@@ -555,9 +657,9 @@ simple_statement:
declaration:
function_prototype ';'
| init_declarator_list ';'
| PRECISION precision_qualifier ext_builtin_type_specifier_nonarray ';'
| type_qualifier id ';'
| type_qualifier id identifier_list ';'
| precision_specifier
;
identifier_list:

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-shader",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-ui",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-xr-webxr",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,6 +1,6 @@
{
"name": "@galacean/engine-xr",
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"

View File

@@ -1,7 +1,7 @@
{
"name": "@galacean/engine-tests",
"private": true,
"version": "2.0.0-alpha.32",
"version": "2.0.0-alpha.33",
"license": "MIT",
"main": "dist/main.js",
"module": "dist/module.js",

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,117 @@ beforeAll(async function () {
@registerGLTFParser(GLTFParserType.Schema)
class GLTFCustomJSONParser extends GLTFParser {
parse(context: GLTFParserContext) {
if (context.glTFResource.url.endsWith("testSkinRoot.gltf")) {
context.buffers = [new ArrayBuffer(192)];
return Promise.resolve({
asset: {
version: "2.0"
},
scene: 0,
scenes: [
{
nodes: [0, 1]
}
],
nodes: [
{
name: "Character_Man"
},
{
name: "mixamorig:Hips",
children: [2]
},
{
name: "mixamorig:Spine"
}
],
skins: [
{
inverseBindMatrices: 0,
// Joints span both top-level scene roots: Character_Man (0) and Hips (1)/Spine (2).
joints: [0, 1, 2]
}
],
accessors: [
{
bufferView: 0,
byteOffset: 0,
componentType: 5126,
count: 3,
type: "MAT4"
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: 192
}
],
buffers: [
{
byteLength: 192
}
]
});
}
if (context.glTFResource.url.endsWith("testSingleSkeleton.gltf")) {
context.buffers = [new ArrayBuffer(128)];
return Promise.resolve({
asset: {
version: "2.0"
},
scene: 0,
scenes: [
{
// Two top-level roots: a character skeleton and an unrelated sibling (e.g., a light).
nodes: [0, 2]
}
],
nodes: [
{
name: "Character_Root",
children: [1]
},
{
name: "mixamorig:Hips"
},
{
name: "Light"
}
],
skins: [
{
inverseBindMatrices: 0,
// All joints converge to a single top-level root (Character_Root).
joints: [0, 1]
}
],
accessors: [
{
bufferView: 0,
byteOffset: 0,
componentType: 5126,
count: 2,
type: "MAT4"
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: 128
}
],
buffers: [
{
byteLength: 128
}
]
});
}
const glTF = <any>{
buffers: [
{
@@ -481,6 +592,17 @@ describe("glTF Loader test", function () {
expect(renderer).to.exist;
expect(renderer.blendShapeWeights).to.deep.include([1, 1]);
});
it("single-root animation root channel should bind to the root node path", async () => {
const glTFResource: GLTFResource = await engine.resourceManager.load({
type: AssetType.GLTF,
url: "mock/path/testA.gltf"
});
const clip = glTFResource.animations?.[0];
expect(clip).to.exist;
expect(clip.curveBindings[0].relativePath).to.equal("entity1");
});
});
describe("glTF instance test", function () {
@@ -530,6 +652,33 @@ describe("glTF scene root structure", function () {
expect(defaultSceneRoot.children.length).to.equal(1);
expect(defaultSceneRoot.children[0].name).to.equal("entity1");
});
it("Multi-root skins without skeleton should use the scene wrapper as rootBone", async () => {
const glTFResource: GLTFResource = await engine.resourceManager.load({
type: AssetType.GLTF,
url: "mock/path/testSkinRoot.gltf"
});
const { defaultSceneRoot, skins } = glTFResource;
expect(defaultSceneRoot.name).to.equal("GLTF_ROOT");
expect(defaultSceneRoot.children.length).to.equal(2);
expect(skins[0].rootBone).to.equal(defaultSceneRoot);
});
it("Multi-root scenes whose joints converge to a single top-level root should not use the scene wrapper", async () => {
const glTFResource: GLTFResource = await engine.resourceManager.load({
type: AssetType.GLTF,
url: "mock/path/testSingleSkeleton.gltf"
});
const { defaultSceneRoot, skins } = glTFResource;
expect(defaultSceneRoot.name).to.equal("GLTF_ROOT");
// Scene has two top-level roots, but all joints converge to "Character_Root".
expect(defaultSceneRoot.children.length).to.equal(2);
expect(skins[0].rootBone).to.not.equal(defaultSceneRoot);
// rootBone should be inside the Character_Root subtree (LCA = Character_Root).
expect(skins[0].rootBone.name).to.equal("Character_Root");
});
});
describe("glTF instance test", function () {

View File

@@ -9,7 +9,7 @@ import { ShaderMacroProcessor } from "@galacean/engine-core/src/shader/ShaderMac
import { Logger, WebGLEngine } from "@galacean/engine";
import { server } from "@vitest/browser/context";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
const { readFile } = server.commands;
Logger.enable();
@@ -1185,6 +1185,51 @@ describe("ShaderCompiler Precompile", async () => {
});
});
// After LALR enforces shift on `macro_call_symbol . '('`, the visitor at
// `K(args)` must distinguish three callee kinds derived from the macro's
// replacement and apply IO-struct formal flattening only when the callee is
// a user-declared function. Earlier the filter ran for every object-like
// macro and silently dropped member-access / literal args (e.g. emitting
// `vec3()` from `MyVec3(attr.POSITION.x, attr.POSITION.y, attr.POSITION.z)`).
describe("MacroCallFunction calleeKind-aware arg filtering", () => {
let evaluated: string;
beforeAll(async () => {
const source = await readFile("./shaders/macro-as-type-args.shader");
const precompiled = shaderCompiler._precompile(source, ShaderLanguage.GLSLES100);
const vi = precompiled.subShaders[0].passes[0].vertexShaderInstructions ?? [];
evaluated = ShaderMacroProcessor.evaluate(vi as any, new Map());
});
// (1) builtin type alias: constructor call, no formal flattening
it("builtin type alias keeps member-access args", () => {
// After flatten, `attr.POSITION.x/y/z` → `POSITION.x/y/z`.
expect(evaluated).toMatch(/vec3\s*\(\s*POSITION\.x\s*,\s*POSITION\.y\s*,\s*POSITION\.z\s*\)/);
});
it("builtin type alias keeps literal args", () => {
expect(evaluated).toMatch(/vec3\s*\(\s*0\.1\s*,\s*0\.2\s*,\s*0\.3\s*\)/);
});
it("builtin type alias never emits an empty constructor call", () => {
// Defends against the original bug where every arg was filtered.
expect(evaluated).not.toMatch(/vec3\s*\(\s*\)/);
});
// (2) builtin function alias: args are values, no flattening
it("builtin function alias keeps all args verbatim", () => {
expect(evaluated).toMatch(/mix\s*\(\s*v_member\s*,\s*v_literal\s*,\s*0\.5\s*\)/);
});
// (3) user-fn alias: formal flattening DOES apply — bare IO-struct arg
// must be dropped so the call site matches the flattened signature.
it("user-fn alias drops bare IO-struct arg paired with flattened formal", () => {
// `computeColor(Attributes a)` flattens to `computeColor()` and call
// becomes `computeColor()` (the `attr` arg drops).
expect(evaluated).toMatch(/computeColor\s*\(\s*\)/);
// The macro name `MyHelper` should still appear in the macro define
// table (the directive itself), but never as `MyHelper(attr)` text.
expect(evaluated).not.toMatch(/MyHelper\s*\(\s*attr\s*\)/);
});
});
// ─────────────────────────────────────────────────────────
// 6. Shader._createFromPrecompiled()
// ─────────────────────────────────────────────────────────

View File

@@ -248,6 +248,22 @@ describe("ShaderCompiler", async () => {
glslValidate(engine, shaderSource, shaderCompilerRelease);
});
// Regression: function-like macro form params and macro-name-as-var cross-arm
// probes used to emit spurious "declared before used" warnings.
it("function-like macro form params and macro names don't warn as undeclared", async () => {
const src = await readFile("./shaders/macro-form-params-no-warn.shader");
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: any[]) => warns.push(args.join(" "));
try {
glslValidate(engine, src, shaderCompilerVerbose);
} finally {
console.warn = origWarn;
}
const undeclared = warns.filter((w) => /will be declared before used/.test(w));
expect(undeclared, `unexpected undeclared warnings:\n${undeclared.join("\n")}`).to.deep.equal([]);
});
it("macro-negate-number (!0, !1 in #if expressions)", async () => {
const shaderSource = await readFile("./shaders/macro-negate-number.shader");
glslValidate(engine, shaderSource, shaderCompilerVerbose);

View File

@@ -0,0 +1,46 @@
Shader "macro-callee-kinds" {
SubShader "Default" {
Pass "test" {
mat4 renderer_MVPMat;
// Object-like macros covering the three callee kinds the visitor must
// distinguish at a `K(args)` call site (LALR enforces shift to
// `macro_call_function`, so this exercises the resulting visitor):
// (1) builtin type alias — constructor call, no formal flattening
#define MyVec3 vec3
// (2) builtin function alias — args are values, no flattening
#define MyMix mix
// (3) user-declared function alias — formals flattened, bare IO-struct
// args must be dropped to match flattened signature
#define MyHelper computeColor
struct Attributes { vec3 POSITION; };
// helper used via macro alias `MyHelper`; its only formal is an IO struct,
// so it gets flattened away and its body reads `POSITION.xyz` directly.
vec3 computeColor(Attributes a) {
return a.POSITION.xyz;
}
void vert(Attributes attr) {
// (1) builtin type via member access — args must be preserved
vec3 v_member = MyVec3(attr.POSITION.x, attr.POSITION.y, attr.POSITION.z);
// (1) builtin type via literals — args must be preserved
vec3 v_literal = MyVec3(0.1, 0.2, 0.3);
// (2) builtin fn alias — args must be preserved verbatim
vec3 v_mix = MyMix(v_member, v_literal, 0.5);
// (3) user-fn alias with bare IO struct — formal-paired, MUST be
// dropped so codegen emits `MyHelper()` matching flattened decl
vec3 v_user = MyHelper(attr);
gl_Position = renderer_MVPMat * vec4(v_member + v_literal + v_mix + v_user, 1.0);
}
void frag() { gl_FragColor = vec4(1.0); }
VertexShader = vert;
FragmentShader = frag;
}
}
}

View File

@@ -0,0 +1,19 @@
Shader "macro-form-params-no-warn" {
SubShader "Default" {
Pass "test" {
// Form params `a` / `mat` ref'd inside the value AST: must not warn.
#define saturate( a ) clamp( a, 0.0, 1.0 )
#define INVERSE_MAT(mat) inverseMat(mat)
// Object-like macro used as a value: cross-arm probe must not warn.
#define HALF_MIN 6.103515625e-5
mat2 inverseMat(mat2 m) { return m; }
void vert() { gl_Position = vec4(saturate(0.5)); }
void frag() {
float dp3 = max(float(HALF_MIN), 1.0);
gl_FragColor = vec4(dp3);
}
VertexShader = vert;
FragmentShader = frag;
}
}
}