From c2eed562e2a02d9df45d16284780978bb036285b Mon Sep 17 00:00:00 2001 From: Baldie & Baldie Date: Sun, 18 Oct 2026 22:42:12 +0200 Subject: [PATCH] Fix foliage scaling, wind and gradient consistency for Three.js r185 --- extensions/community/FoliageSwaying3D.json | 811 +++++++++++++++++---- 1 file changed, 652 insertions(+), 159 deletions(-) diff --git a/extensions/community/FoliageSwaying3D.json b/extensions/community/FoliageSwaying3D.json index b8aab6482..24b1cb789 100644 --- a/extensions/community/FoliageSwaying3D.json +++ b/extensions/community/FoliageSwaying3D.json @@ -10,7 +10,7 @@ "name": "FoliageSwaying3D", "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/732ef90b7fcf5dd9171fe95d0cf262e09159b487304915a4693baa893d9ce16c_grass.svg", "shortDescription": "Sway foliage of 3D objects according to the wind.", - "version": "1.1.0", + "version": "1.2.0", "description": [ "Sway foliage of 3D objects according to wind.", "", @@ -30,6 +30,10 @@ "* Hot reload supports all basic behavior property changes, excluding Sway settings (`Material to sway`, `Sway type`, `Enable uniform sway toggle`) and `Distance culling`, `Debug output`, `GPU instancing`, and `Custom PBR settings` toggles. However, if PBR settings are enabled as you start the preview, changes to specific PBR values will be updated on hot-reload.

", "" ], + "origin": { + "identifier": "FoliageSwaying3D", + "name": "gdevelop-extension-store" + }, "tags": [ "3D", "shader", @@ -123,7 +127,7 @@ " /** @typedef {FoliagePrimitiveValue|gdjs.Variable} FoliageArgumentValue */", " /** @typedef {{ getAsBoolean?: () => boolean, getAsNumber?: () => number, getAsString?: () => string }} FoliageArgumentVariableLike */", " /** @typedef {{ getArgument: (name: string) => FoliageArgumentValue }} FoliageEventsFunctionContext */", - " /** @typedef {{ runtimeScene: gdjs.RuntimeScene|null, dt: number, time: number, windStrength: number, windSpeed: number, wx: number, wy: number, cam: THREE.Camera|null, camX: number, camY: number, camZ: number, frustum: THREE.Frustum|null, _frustumBuilt: boolean }} SharedFrameContext */", + " /** @typedef {{ runtimeScene: gdjs.RuntimeScene|null, inverseWorldScale: number, dt: number, time: number, windStrength: number, windSpeed: number, wx: number, wy: number, cam: THREE.Camera|null, camX: number, camY: number, camZ: number, frustum: THREE.Frustum|null, _frustumBuilt: boolean }} SharedFrameContext */", " /** @typedef {{ _getMaterialName: () => string, _getSwayType: () => string, _getUseUniformSway: () => boolean, _getUseCustomPBR: () => boolean, _getMetallic: () => number, _getRoughness: () => number, _getSpecular: () => number, _getNormalStrength: () => number, _getAmbientOcclusionStrength: () => number, _getEnvironmentStrength: () => number, _getCullingMode: () => string, _getUseTwoSidedLighting: () => boolean, _getUseGPUInstancing: () => boolean, _getUseDistanceFade: () => boolean, _getDistanceFadeStart: () => number, _getDistanceFadeEnd: () => number, _getSwayResponse: () => number, _getIgnoreUV: () => boolean, _getGradientStart: () => number, _getGradientEnd: () => number, _getUseColorGradient: () => boolean, _getTopColor: () => string, _getBottomColor: () => string, _getColorContrast: () => number, _getColorSaturation: () => number, _getDebugOutput: () => boolean }} FoliageBehaviorGetterMethods */", " /**", " * Typed snapshot of behavior properties read once for a single runtime use-site.", @@ -237,6 +241,7 @@ " registerActiveMaterial(mat) {", " if (!mat) return;", " var state = this.state;", + " state.syncMaterialGustDefine(mat);", " state.activeMaterials.add(mat);", " this.ensureBuckets().addUnknown(mat);", " }", @@ -280,11 +285,11 @@ " var state = this.state;", " state.activeMaterials.forEach(function(mat) {", " if (!mat) return;", - " mat.needsUpdate = true;", + " state.syncMaterialGustDefine(mat);", " var ud = mat.userData;", " if (!ud) return;", - " if (ud._foliageDepthMat) ud._foliageDepthMat.needsUpdate = true;", - " if (ud._foliageDistanceMat) ud._foliageDistanceMat.needsUpdate = true;", + " state.syncMaterialGustDefine(ud._foliageDepthMat);", + " state.syncMaterialGustDefine(ud._foliageDistanceMat);", " });", " }", " }", @@ -296,10 +301,10 @@ " /** @typedef {{ srcRef: THREE.Material|null, pickedId: string, matchMode: \"name\"|\"auto\", matchName: string }} FoliageMaterialSelection */", " /** @typedef {{ repMesh?: THREE.Object3D|null, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|null, baseGroupKey?: string }} FoliagePendingPart */", " /** @typedef {{ gdObj?: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string, getX?: () => number, getY?: () => number, getZ?: () => number, setX?: (x: number) => void, setY?: (y: number) => void, setZ?: (z: number) => void, getScaleX?: () => number, getScaleY?: () => number, getScaleZ?: () => number, getRotationX?: () => number, getRotationY?: () => number, getRotationZ?: () => number, getAngle?: () => number, hide?: (enable?: boolean) => void, isHidden?: () => boolean, deleteFromScene?: (runtimeScene?: gdjs.RuntimeScene) => void })|null, threeObj?: THREE.Object3D|null, repMesh?: THREE.Object3D|null, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|null, parent?: THREE.Object3D|null, baseGroupKey?: string, groupKey?: string, swayType?: string, behavior?: FoliageBehavior|null, queueId?: number, objectName?: string, behaviorName?: string, parts?: FoliagePendingPart[] }} FoliagePendingItem */", - " /** @typedef {{ gdObj?: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string, getX?: () => number, getY?: () => number, getZ?: () => number, getScaleX?: () => number, getScaleY?: () => number, getScaleZ?: () => number, getRotationX?: () => number, getRotationY?: () => number, getRotationZ?: () => number, getAngle?: () => number, hide?: (enable?: boolean) => void, isHidden?: () => boolean, deleteFromScene?: (runtimeScene?: gdjs.RuntimeScene) => void })|null, threeObj?: THREE.Object3D|null, material?: THREE.Material|null, trunkMaterial?: THREE.Material|null, fadeStart?: number, fadeEnd?: number, fadeEnabled?: boolean, fadeBehavior?: FoliageBehavior|null, _wasHidden?: boolean, _parkedNoFade?: boolean, _firstHideWarmupDone?: boolean }} FoliageNonInstancedEntry */", - " /** @typedef {{ key?: string, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|null, parent?: THREE.Object3D|null, matricesBuffer?: Float32Array|null, centersXY?: Float32Array|null, centersZ?: Float32Array|null, cullRadii?: Float32Array|null, matrixCount?: number, aliveCount?: number, freeIndices?: number[], freeIndexSet?: Set, capacity?: number, mesh?: THREE.InstancedMesh|null, castShadow?: boolean, receiveShadow?: boolean, fadeEnabled?: boolean, fadeStart?: number, fadeEnd?: number, _fadeBehavior?: FoliageBehavior|null, _fadeSig?: string, _instanceCullRadius?: number, instanceFade?: Float32Array|null, instanceFadePrev?: Float32Array|null, _distanceCandidateIndices?: Uint32Array|null, _distanceCandidateCount?: number, _fadeDisabledApplied?: boolean, _lastFadeEnabled?: boolean, visibleCount?: number, instanceDetSign?: number, _srcGeometryRef?: THREE.BufferGeometry|null, _ownedGeometry?: THREE.BufferGeometry|null }} FoliageInstancingGroup */", + " /** @typedef {{ gdObj?: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string, getX?: () => number, getY?: () => number, getZ?: () => number, getScaleX?: () => number, getScaleY?: () => number, getScaleZ?: () => number, getRotationX?: () => number, getRotationY?: () => number, getRotationZ?: () => number, getAngle?: () => number, hide?: (enable?: boolean) => void, isHidden?: () => boolean, deleteFromScene?: (runtimeScene?: gdjs.RuntimeScene) => void })|null, threeObj?: THREE.Object3D|null, material?: THREE.Material|null, trunkMaterial?: THREE.Material|null, fadeStart?: number, fadeEnd?: number, fadeEnabled?: boolean, fadeBehavior?: FoliageBehavior|null, gradientSources?: FoliageGradientSource[], _wasHidden?: boolean, _parkedNoFade?: boolean, _firstHideWarmupDone?: boolean }} FoliageNonInstancedEntry */", + " /** @typedef {{ key?: string, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|null, parent?: THREE.Object3D|null, matricesBuffer?: Float32Array|null, gradientBounds?: Float32Array|null, centersXY?: Float32Array|null, centersZ?: Float32Array|null, cullRadii?: Float32Array|null, matrixCount?: number, aliveCount?: number, freeIndices?: number[], freeIndexSet?: Set, capacity?: number, mesh?: THREE.InstancedMesh|null, castShadow?: boolean, receiveShadow?: boolean, fadeEnabled?: boolean, fadeStart?: number, fadeEnd?: number, _fadeBehavior?: FoliageBehavior|null, _fadeSig?: string, _instanceCullRadius?: number, instanceFade?: Float32Array|null, instanceFadePrev?: Float32Array|null, _distanceCandidateIndices?: Uint32Array|null, _distanceCandidateCount?: number, _fadeDisabledApplied?: boolean, _lastFadeEnabled?: boolean, visibleCount?: number, instanceDetSign?: number, _srcGeometryRef?: THREE.BufferGeometry|null, _ownedGeometry?: THREE.BufferGeometry|null }} FoliageInstancingGroup */", " /** @typedef {{ groups: Map, typeGroupKeys: Map>, groupTypeKeys: Map>, typeRepresentatives: Map, dirty: boolean, sceneTag: string|null, pending: FoliagePendingItem[], queueIdCounter: number, cancelledQueueIds: Set, foliageRoot: THREE.Object3D|null, _cachedSceneRoot: THREE.Object3D|null, _isReady?: boolean, _tmpRootSet?: Set|null, _tmpMat4?: THREE.Matrix4, _tmpObj3D?: THREE.Object3D, _tmpVec3_pos?: THREE.Vector3, _tmpVec3_scale?: THREE.Vector3, _tmpEuler?: THREE.Euler, _tmpQuat?: THREE.Quaternion, _tmpMat4_objWorld?: THREE.Matrix4, _tmpMat4_objWorldInv?: THREE.Matrix4, _tmpMat4_repWorld?: THREE.Matrix4, _repRelByMesh?: WeakMap, _itemsByParentRepMesh?: Map, _repRelMatrixCache?: Map, _tmpMat4_local?: THREE.Matrix4, _tmpMat4_foliageRootInv?: THREE.Matrix4 }} FoliageInstancingStateShape */", - " /** @typedef {{ records?: FoliageObjectRecord[], statsByRef?: Map, statsByName?: Map, selection?: FoliageMaterialSelection|null, _cachedGeometry?: THREE.BufferGeometry|null, _gpuGeometry?: THREE.BufferGeometry|null, _gpuGroupKey?: string, _sharedMaterialKey?: string, _resolvedSide?: THREE.Side, _gpuFastMode?: \"single\"|\"splitLeavesTree\", _gpuLeavesGeometry?: THREE.BufferGeometry|null, _gpuTrunkGeometry?: THREE.BufferGeometry|null, _gpuLeavesBaseGroupKey?: string, _gpuTrunkBaseGroupKey?: string, _gpuLeavesSharedMaterialKey?: string, _gpuTrunkSharedMaterialKey?: string, _gpuLeavesResolvedSide?: THREE.Side, _gpuTrunkResolvedSide?: THREE.Side }} FoliageObjectTypeCacheEntry */", + " /** @typedef {{ records?: FoliageObjectRecord[], statsByRef?: Map, statsByName?: Map, selection?: FoliageMaterialSelection|null, _cachedGeometry?: THREE.BufferGeometry|null, _gpuSourceGeometry?: THREE.BufferGeometry|null, _gpuGeometry?: THREE.BufferGeometry|null, _gpuGroupKey?: string, _sharedMaterialKey?: string, _resolvedSide?: THREE.Side, _gpuFastMode?: \"single\"|\"splitLeavesTree\", _gpuLeavesSourceGeometry?: THREE.BufferGeometry|null, _gpuTrunkSourceGeometry?: THREE.BufferGeometry|null, _gpuLeavesGeometry?: THREE.BufferGeometry|null, _gpuTrunkGeometry?: THREE.BufferGeometry|null, _gpuLeavesBaseGroupKey?: string, _gpuTrunkBaseGroupKey?: string, _gpuLeavesSharedMaterialKey?: string, _gpuTrunkSharedMaterialKey?: string, _gpuLeavesResolvedSide?: THREE.Side, _gpuTrunkResolvedSide?: THREE.Side }} FoliageObjectTypeCacheEntry */", " /**", " * Cached result of the automatic sway response heuristic.", " * The `swayResponse` field is the shader multiplier; when the behavior sets", @@ -308,11 +313,14 @@ " * @typedef {{ swayResponse: number, swayResponseRaw: number, sizeFactor: number, complexityFactor: number, vertexFactor: number, triFactor: number, structureFactor: number, planeFactor: number, responseGain: number, planeLikeRatio: number, baseByType: number, boundsMode: string }} FoliageAutoSwayResponseEntry", " */", " /** @typedef {{ isAlphaLikely: (mat: FoliageInspectableMaterial|null|undefined) => boolean, scoreMaterial: (mat: FoliageInspectableMaterial|null|undefined) => number }} FoliageObjectTypeAnalysisHelpers */", - " /** @typedef {{ patchedMaterials: WeakSet, sharedByKey: Map, activeMaterials: Set, materialRegistry: FoliageSharedMaterialRegistry, objectTypeCacheRegistry: FoliageObjectTypeCache, nonInstancedRegistry: FoliageNonInstancedRegistry, instancingState: FoliageInstancingState, instancingCoordinator: FoliageInstancingCoordinator, cullingCoordinator: FoliageCullingCoordinator, shadowUniformSync: FoliageShadowUniformSync, time: number, gustVersion: number, debugPrinted: Set, gustEnabled: boolean, gustStrength: number, gustScale: number, gustSpeed: number, gustThreshold: number, gustContrast: number, gustTexture: THREE.Texture|null, gustTextureResourceName: string, gustFallbackTex: THREE.Texture|null, _gustFallbackIsStripe: boolean, _gustDefineDirty: boolean, fadeUpdateHz?: number, cullAccum?: number, cullInterval?: number, _lastLoggedFadeHz?: number, lastCullTime?: number, _lastFadeUpdateHz?: number, lastCamX?: number, lastCamY?: number, lastCamZ?: number, _fadeParamsDirty?: boolean, objectTypeCache: Map, _objectTypeCacheState: FoliageObjectTypeCacheState, geometryBBoxCache: WeakMap, autoSwayResponseCache: Map, nonInstancedFadeObjects: FoliageNonInstancedEntry[], nonInstancedStaticObjects: FoliageNonInstancedEntry[], _nonInstancedStaticCheckAccum: number, instancing: FoliageInstancingState, GUST_DEFAULTS: GustDefaults, _materialBuckets: FoliageMaterialBuckets, _frameCtx: SharedFrameContext|null, _frustumShared: THREE.Frustum|null, _projViewMatrixShared: THREE.Matrix4|null, _behaviorPropsCache?: WeakMap|null, _tmpInstanceCullSphere?: { cx: number, cy: number, cz: number, radius: number }, _tmpInstanceCullSphereA?: { cx: number, cy: number, cz: number, radius: number }, _tmpInstanceCullSphereB?: { cx: number, cy: number, cz: number, radius: number }, _tmpInstanceCullSphereShared?: { cx: number, cy: number, cz: number, radius: number }, _tmpVec3NonInstancedFade: THREE.Vector3|null, _libReady: boolean, getBehaviorProps: (behavior: FoliageBehavior|null|undefined) => FoliageBehaviorProps|null, syncObjectHotReload: (gdObj: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string })|null|undefined, behavior: FoliageBehavior|null|undefined, behaviorName?: string) => boolean, resetMaterialBuckets: () => void, registerActiveMaterial: (mat: THREE.Material|null|undefined) => void, unregisterActiveMaterial: (mat: THREE.Material|null|undefined) => void, disposeShadowMaterials: (mat: THREE.Material|null|undefined) => void, markGustForRecompile: () => void, ensureGustFallbackTex: () => THREE.Texture|null, disposeNonFallbackTex: (tex: THREE.Texture|null|undefined) => void, cleanupForSceneChange: () => void, _syncObjectTypeCacheAliases: () => FoliageSceneState, _syncNonInstancedAliases: () => FoliageSceneState, _syncInstancingAliases: () => FoliageSceneState, _readBoolArg: (efc: FoliageEventsFunctionContext, name: string, fallback: boolean) => boolean, _readNumArg: (efc: FoliageEventsFunctionContext, name: string, fallback: number) => number, _readStringArg: (efc: FoliageEventsFunctionContext, name: string, fallback: string) => string, buildFrameContext: (runtimeScene: gdjs.RuntimeScene, eventsFunctionContext: FoliageEventsFunctionContext) => SharedFrameContext, _ensureFrustum: (ctx: SharedFrameContext) => THREE.Frustum|null }} FoliageSceneStateShape */", - " /** @typedef {{ _libReady: boolean, getSceneState: (runtimeScene: gdjs.RuntimeScene) => FoliageSceneState, normalizeCullingMode: (v: FoliagePrimitiveValue) => { name: string, code: number }, resolveRenderSide: (sourceSide: THREE.Side|undefined|null, cullingModeName: string) => THREE.Side, buildSideSuffix: (cullingModeCode: number, renderSide: THREE.Side|undefined|null) => string, buildPbrSuffix: (customPBREnabledValue: boolean, metallicValue: number, roughnessValue: number, specularValue: number, normalStrengthValue: number, ambientOcclusionStrengthValue: number, environmentStrengthValue: number) => string, buildGpuFastCacheKey: (localSharedKey: string, localMaterialName: string, localSwayType: string, localDistanceFadeEnabled: boolean, localPbrSuffix: string, localSideSuffix: string, localTwoSidedLighting: boolean, localSwayResponseAutoMode: boolean) => string, parseColor: (str: string, fallbackHex: string) => THREE.Color, isAlphaLikely: (mat: FoliageInspectableMaterial|null|undefined) => boolean, scoreMaterial: (mat: FoliageInspectableMaterial|null|undefined) => number, sanitizeKeyPart: (s: string) => string, meshMatchesSelection: (mesh: FoliageMeshLike, selection: FoliageMaterialSelection, srcRef: THREE.Material|null, srcName: string) => boolean, findFirstMatchingMesh: (root: THREE.Object3D, selection: FoliageMaterialSelection, srcRef: THREE.Material|null, srcName: string) => FoliageMeshLike|null, findSplitTreeMeshes: (root: THREE.Object3D, trunkMatcher: (mesh: FoliageMeshLike) => boolean, leavesMatcher: (mesh: FoliageMeshLike) => boolean) => { trunk: FoliageMeshLike|null, leaves: FoliageMeshLike|null } }} FoliageExtensionApi */", + " /** @typedef {{ patchedMaterials: WeakSet, sharedByKey: Map, activeMaterials: Set, materialRegistry: FoliageSharedMaterialRegistry, objectTypeCacheRegistry: FoliageObjectTypeCache, nonInstancedRegistry: FoliageNonInstancedRegistry, instancingState: FoliageInstancingState, instancingCoordinator: FoliageInstancingCoordinator, cullingCoordinator: FoliageCullingCoordinator, shadowUniformSync: FoliageShadowUniformSync, time: number, gustVersion: number, debugPrinted: Set, gustEnabled: boolean, gustStrength: number, gustScale: number, gustSpeed: number, gustThreshold: number, gustContrast: number, gustTexture: THREE.Texture|null, gustTextureResourceName: string, gustFallbackTex: THREE.Texture|null, _gustFallbackIsStripe: boolean, _gustDefineDirty: boolean, fadeUpdateHz?: number, cullAccum?: number, cullInterval?: number, _lastLoggedFadeHz?: number, lastCullTime?: number, _lastFadeUpdateHz?: number, lastCamX?: number, lastCamY?: number, lastCamZ?: number, _fadeParamsDirty?: boolean, objectTypeCache: Map, _objectTypeCacheState: FoliageObjectTypeCacheState, geometryBBoxCache: WeakMap, autoSwayResponseCache: Map, nonInstancedFadeObjects: FoliageNonInstancedEntry[], nonInstancedStaticObjects: FoliageNonInstancedEntry[], _nonInstancedStaticCheckAccum: number, instancing: FoliageInstancingState, GUST_DEFAULTS: GustDefaults, _materialBuckets: FoliageMaterialBuckets, _frameCtx: SharedFrameContext|null, _frustumShared: THREE.Frustum|null, _projViewMatrixShared: THREE.Matrix4|null, _behaviorPropsCache?: WeakMap|null, _tmpInstanceCullSphere?: { cx: number, cy: number, cz: number, radius: number }, _tmpInstanceCullSphereA?: { cx: number, cy: number, cz: number, radius: number }, _tmpInstanceCullSphereB?: { cx: number, cy: number, cz: number, radius: number }, _tmpInstanceCullSphereShared?: { cx: number, cy: number, cz: number, radius: number }, _tmpVec3NonInstancedFade: THREE.Vector3|null, _libReady: boolean, getBehaviorProps: (behavior: FoliageBehavior|null|undefined) => FoliageBehaviorProps|null, syncObjectHotReload: (gdObj: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string })|null|undefined, behavior: FoliageBehavior|null|undefined, behaviorName?: string) => boolean, resetMaterialBuckets: () => void, registerActiveMaterial: (mat: THREE.Material|null|undefined) => void, unregisterActiveMaterial: (mat: THREE.Material|null|undefined) => void, disposeShadowMaterials: (mat: THREE.Material|null|undefined) => void, markGustForRecompile: () => void, syncMaterialGustDefine: (mat: THREE.Material|null|undefined) => void, ensureGustFallbackTex: () => THREE.Texture|null, disposeNonFallbackTex: (tex: THREE.Texture|null|undefined) => void, cleanupForSceneChange: () => void, _syncObjectTypeCacheAliases: () => FoliageSceneState, _syncNonInstancedAliases: () => FoliageSceneState, _syncInstancingAliases: () => FoliageSceneState, _readBoolArg: (efc: FoliageEventsFunctionContext, name: string, fallback: boolean) => boolean, _readNumArg: (efc: FoliageEventsFunctionContext, name: string, fallback: number) => number, _readStringArg: (efc: FoliageEventsFunctionContext, name: string, fallback: string) => string, buildFrameContext: (runtimeScene: gdjs.RuntimeScene, eventsFunctionContext: FoliageEventsFunctionContext) => SharedFrameContext, _ensureFrustum: (ctx: SharedFrameContext) => THREE.Frustum|null }} FoliageSceneStateShape */", + " /** @typedef {{ _libReady: boolean, getSceneState: (runtimeScene: gdjs.RuntimeScene) => FoliageSceneState, normalizeCullingMode: (v: FoliagePrimitiveValue) => { name: string, code: number }, resolveRenderSide: (sourceSide: THREE.Side|undefined|null, cullingModeName: string) => THREE.Side, buildSideSuffix: (cullingModeCode: number, renderSide: THREE.Side|undefined|null) => string, buildPbrSuffix: (customPBREnabledValue: boolean, metallicValue: number, roughnessValue: number, specularValue: number, normalStrengthValue: number, ambientOcclusionStrengthValue: number, environmentStrengthValue: number) => string, buildGpuFastCacheKey: (localSharedKey: string, localMaterialName: string, localSwayType: string, localDistanceFadeEnabled: boolean, localPbrSuffix: string, localSideSuffix: string, localTwoSidedLighting: boolean, localSwayResponseAutoMode: boolean) => string, parseColor: (str: string, fallbackHex: string) => THREE.Color, isAlphaLikely: (mat: FoliageInspectableMaterial|null|undefined) => boolean, scoreMaterial: (mat: FoliageInspectableMaterial|null|undefined) => number, sanitizeKeyPart: (s: string) => string, refreshGradientBounds: (sources: FoliageGradientSource[], bounds: { worldMin: number, worldMax: number }, inverseWorldScale: number) => boolean, meshMatchesSelection: (mesh: FoliageMeshLike, selection: FoliageMaterialSelection, srcRef: THREE.Material|null, srcName: string) => boolean, findFirstMatchingMesh: (root: THREE.Object3D, selection: FoliageMaterialSelection, srcRef: THREE.Material|null, srcName: string) => FoliageMeshLike|null, findSplitTreeMeshes: (root: THREE.Object3D, trunkMatcher: (mesh: FoliageMeshLike) => boolean, leavesMatcher: (mesh: FoliageMeshLike) => boolean) => { trunk: FoliageMeshLike|null, leaves: FoliageMeshLike|null } }} FoliageExtensionApi */", " /** @typedef {typeof gdjs & { _foliageSwaying3D?: FoliageExtensionApi }} FoliageGdjs */", " /** @typedef {gdjs.RuntimeScene & { _foliageSwaying3D?: FoliageSceneState }} FoliageRuntimeScene */", " ", + " /** @typedef {{ bounds: THREE.Box3, indices: number[], geometry: THREE.BufferGeometry|null }} FoliageMaterialSubset */", + " /** @typedef {{ mesh: FoliageMeshLike, bounds: THREE.Box3, zRow: number[] }} FoliageGradientSource */", + "", " /**", " * Cache for expensive per-object-type mesh/material analysis.", " * onCreated uses this to reuse selection, stats, and fast-path metadata across", @@ -330,6 +338,10 @@ " this.autoSwayResponseCache = new Map();", " /** @type {Set} */", " this.debugPrinted = new Set();", + " /** @type {WeakMap>} */", + " this.materialSubsets = new WeakMap();", + " /** @type {Set} */", + " this.ownedSubsetGeometries = new Set();", " this.reset();", " }", "", @@ -347,6 +359,11 @@ " }", " entry._cachedGeometry = null;", " entry._gpuGeometry = null;", + " entry._gpuSourceGeometry = null;", + " entry._gpuLeavesGeometry = null;", + " entry._gpuTrunkGeometry = null;", + " entry._gpuLeavesSourceGeometry = null;", + " entry._gpuTrunkSourceGeometry = null;", " }", "", " /**", @@ -359,6 +376,10 @@ " this._clearEntryCachedRefs(entry);", " }", " }", + " // Scene cleanup removes instancing groups before releasing their shared subsets.", + " for (var geometry of this.ownedSubsetGeometries) geometry.dispose();", + " this.ownedSubsetGeometries.clear();", + " this.materialSubsets = new WeakMap();", " this.entries = new Map();", " this.state = { tick: 0, setCount: 0, meta: new Map() };", " this.geometryBBoxCache = new WeakMap();", @@ -367,6 +388,123 @@ " }", "", " /**", + " * Cache the triangles actually drawn for a material selection. A single material", + " * ignores groups, just like Three.js; material arrays render only their groups.", + " * @param {THREE.BufferGeometry} source", + " * @param {number[]|null} slots", + " * @returns {FoliageMaterialSubset}", + " */", + " getMaterialSubset(source, slots) {", + " var key = slots === null ? \"single\" : slots.join(\",\");", + " var selections = this.materialSubsets.get(source);", + " if (!selections) {", + " selections = new Map();", + " this.materialSubsets.set(source, selections);", + " }", + " var cached = selections.get(key);", + " if (cached) return cached;", + " var position = source.getAttribute(\"position\");", + " var index = source.getIndex();", + " var count = position ? (index ? index.count : position.count) : 0;", + " var drawStart = Math.max(0, source.drawRange.start);", + " var drawEnd = Math.min(count, source.drawRange.start + source.drawRange.count);", + " var ranges = slots === null", + " ? [{ start: drawStart, count: drawEnd - drawStart, materialIndex: 0 }]", + " : source.groups.filter(function(group) { return slots.indexOf(group.materialIndex || 0) !== -1; });", + " var bounds = new THREE.Box3();", + " var point = new THREE.Vector3();", + " /** @type {number[]} */", + " var indices = [];", + " for (var range of ranges) {", + " var start = Math.max(drawStart, range.start);", + " var end = Math.min(drawEnd, range.start + range.count);", + " for (var i = start; i + 2 < end; i += 3) {", + " for (var j = 0; j < 3; j++) {", + " var vertex = index ? index.getX(i + j) : i + j;", + " indices.push(vertex);", + " point.fromBufferAttribute(position, vertex);", + " bounds.expandByPoint(point);", + " }", + " }", + " }", + " cached = { bounds: bounds, indices: indices, geometry: null };", + " selections.set(key, cached);", + " return cached;", + " }", + "", + " /**", + " * Prepare a shared, compact geometry, retaining every vertex/morph attribute.", + " * Source geometry and materials remain owned by GDevelop.", + " * @param {FoliageMeshLike} mesh", + " * @param {FoliageMaterialSelection} selection", + " * @returns {THREE.BufferGeometry}", + " */", + " getRenderGeometry(mesh, selection) {", + " var subset = this.getMaterialSubset(mesh.geometry, selectedMaterialSlots(mesh, selection));", + " if (!subset.geometry) {", + " var indexed = mesh.geometry.clone();", + " indexed.setIndex(subset.indices);", + " indexed.clearGroups();", + " indexed.setDrawRange(0, subset.indices.length);", + " subset.geometry = indexed.toNonIndexed();", + " indexed.dispose();", + " subset.geometry.computeBoundingBox();", + " subset.geometry.computeBoundingSphere();", + " this.ownedSubsetGeometries.add(subset.geometry);", + " }", + " return subset.geometry;", + " }", + "", + " /**", + " * Capture mesh references before their source materials are replaced.", + " * @param {THREE.Object3D} root", + " * @param {FoliageMaterialSelection} selection", + " * @returns {FoliageGradientSource[]}", + " */", + " collectGradientSources(root, selection) {", + " /** @type {FoliageGradientSource[]} */", + " var sources = [];", + " var registry = this;", + " root.traverse(function(node) {", + " var mesh = /** @type {FoliageMeshLike} */ (node);", + " if (!meshMatchesSelection(mesh, selection, selection.srcRef, selection.matchName)) return;", + " var subset = registry.getMaterialSubset(mesh.geometry, selectedMaterialSlots(mesh, selection));", + " if (!subset.bounds.isEmpty()) sources.push({ mesh: mesh, bounds: subset.bounds, zRow: [NaN, NaN, NaN, NaN] });", + " });", + " return sources;", + " }", + "", + " /**", + " * The current batch layout supports one draw, or one trunk and one leaves draw.", + " * Keep more complex assets intact rather than silently dropping their meshes.", + " * @param {THREE.Object3D} root", + " * @param {FoliageMaterialSelection} selection", + " * @param {string} swayType", + " * @returns {boolean}", + " */", + " supportsInstancing(root, selection, swayType) {", + " var registry = this;", + " var draws = 0, selectedDraws = 0, slots = 0;", + " var supported = true;", + " root.traverse(function(node) {", + " var mesh = /** @type {FoliageMeshLike & { isSkinnedMesh?: boolean, isInstancedMesh?: boolean }} */ (node);", + " if (!mesh.isMesh || !mesh.geometry || !mesh.material) return;", + " if (mesh.isSkinnedMesh || mesh.isInstancedMesh || Object.keys(mesh.geometry.morphAttributes).length) supported = false;", + " var materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];", + " for (var i = 0; i < materials.length; i++) {", + " if (!materials[i]) continue;", + " slots++;", + " var subset = registry.getMaterialSubset(mesh.geometry, Array.isArray(mesh.material) ? [i] : null);", + " if (!subset.indices.length) continue;", + " draws++;", + " if (materialMatchesSelection(materials[i], selection)) selectedDraws++;", + " else if (!materials[i].name) supported = false;", + " }", + " });", + " return supported && draws === slots && selectedDraws === 1 && (draws === 1 || (swayType === \"Leaves\" && draws === 2));", + " }", + "", + " /**", " * Classify a cache key so pruning can cap each cache family independently.", " * @param {string} key", " * @returns {\"rop\"|\"stats\"|\"fast\"}", @@ -635,7 +773,8 @@ " st.meshCount++;", " }", " }", - " root.updateMatrixWorld(true);", + " // Include ancestors: the layer's world scale may not have reached matrixWorld yet.", + " root.updateWorldMatrix(true, true);", " var tmpBox = new THREE.Box3();", " var tmpBoxLocal = new THREE.Box3();", " var rootWorldInv = new THREE.Matrix4();", @@ -1072,10 +1211,28 @@ " cfg.colorContrast = props.colorContrast;", " cfg.useColorGradient = !!props.useColorGradient;", " cfg.twoSidedLighting = !!props.twoSidedLighting;", - " cfg.ignoreUV = !!props.ignoreUV;", + " cfg.ignoreUV = cfg.useColorGradient && !!props.ignoreUV;", " cfg.gradientStart = props.gradientStart;", " cfg.gradientEnd = props.gradientEnd;", "", + " // Signed shadow variants own config copies, so switch their bounds as well.", + " var gradientMaterials = [mat, mat.userData._foliageDepthMat, mat.userData._foliageDistanceMat];", + " for (var gi = 0; gi < gradientMaterials.length; gi++) {", + " var gradientMat = gradientMaterials[gi];", + " var gradientCfg = gradientMat && gradientMat.userData ? gradientMat.userData.foliageConfig : null;", + " if (!gradientCfg || !gradientCfg.gradientBounds) continue;", + " var bounds = gradientCfg.gradientBounds;", + " gradientCfg.ignoreUV = cfg.ignoreUV;", + " gradientCfg.gradientLocalZMin = cfg.ignoreUV ? bounds.worldMin : bounds.localMin;", + " gradientCfg.gradientLocalZMax = cfg.ignoreUV ? bounds.worldMax : bounds.localMax;", + " var gradientUniforms = gradientMat.userData.foliageUniforms;", + " if (gradientUniforms) {", + " if (gradientUniforms.uUseWorldGradient) gradientUniforms.uUseWorldGradient.value = cfg.ignoreUV ? 1.0 : 0.0;", + " if (gradientUniforms.uGradientLocalZMin) gradientUniforms.uGradientLocalZMin.value = gradientCfg.gradientLocalZMin;", + " if (gradientUniforms.uGradientLocalZMax) gradientUniforms.uGradientLocalZMax.value = gradientCfg.gradientLocalZMax;", + " }", + " }", + "", " var u = mat.userData.foliageUniforms;", " var shouldSyncSwayResponse = !!cfg.swayType || typeof cfg.swayResponse === \"number\" || !!(u && u.uSwayResponse);", " if (shouldSyncSwayResponse) {", @@ -1358,6 +1515,61 @@ " return String(s).replace(/::/g, \"_\").trim() || \"(empty)\";", " }", "", + " /** @param {THREE.Material} material @param {FoliageMaterialSelection} selection @returns {boolean} */", + " function materialMatchesSelection(material, selection) {", + " return selection.matchMode === \"name\"", + " ? material.name === selection.matchName", + " : material === selection.srcRef || !!(selection.matchName && material.name === selection.matchName && isAlphaLikely(material));", + " }", + "", + " /** @param {FoliageMeshLike} mesh @param {FoliageMaterialSelection} selection @returns {number[]|null} */", + " function selectedMaterialSlots(mesh, selection) {", + " if (!Array.isArray(mesh.material)) return null;", + " var slots = [];", + " for (var i = 0; i < mesh.material.length; i++) {", + " if (mesh.material[i] && materialMatchesSelection(mesh.material[i], selection)) slots.push(i);", + " }", + " return slots;", + " }", + "", + " /**", + " * Project cached local boxes onto world Z. Only the Z matrix row affects this", + " * gradient; checking it avoids vertex scans and uniform writes for static trees.", + " * @param {FoliageGradientSource[]} sources", + " * @param {{ worldMin: number, worldMax: number }} bounds", + " * @param {number} inverseWorldScale", + " * @returns {boolean}", + " */", + " function refreshGradientBounds(sources, bounds, inverseWorldScale) {", + " var changed = false;", + " for (var source of sources) {", + " var e = source.mesh.matrixWorld.elements;", + " var row = source.zRow;", + " if (row[0] !== e[2] || row[1] !== e[6] || row[2] !== e[10] || row[3] !== e[14]) changed = true;", + " }", + " if (!changed) return false;", + " var min = Infinity, max = -Infinity;", + " for (var source of sources) {", + " var e = source.mesh.matrixWorld.elements, box = source.bounds;", + " var row = source.zRow;", + " row[0] = e[2]; row[1] = e[6]; row[2] = e[10]; row[3] = e[14];", + " var x0 = e[2] * box.min.x, x1 = e[2] * box.max.x;", + " var y0 = e[6] * box.min.y, y1 = e[6] * box.max.y;", + " var z0 = e[10] * box.min.z, z1 = e[10] * box.max.z;", + " min = Math.min(min, e[14] + Math.min(x0, x1) + Math.min(y0, y1) + Math.min(z0, z1));", + " max = Math.max(max, e[14] + Math.max(x0, x1) + Math.max(y0, y1) + Math.max(z0, z1));", + " }", + " if (!isFinite(min) || !isFinite(max)) return false;", + " // Degenerate horizontal cards still need a nonzero range in the shader.", + " if (max <= min) {", + " min -= inverseWorldScale * 0.5;", + " max += inverseWorldScale * 0.5;", + " }", + " bounds.worldMin = min;", + " bounds.worldMax = max;", + " return true;", + " }", + "", " /**", " * @param {FoliageMeshLike} mesh", " * @param {FoliageMaterialSelection} selection", @@ -1627,6 +1839,7 @@ " entry.fadeEnd = entryData.fadeEnd;", " entry.fadeEnabled = !!entryData.fadeEnabled;", " entry.fadeBehavior = entryData.fadeBehavior || null;", + " entry.gradientSources = entryData.gradientSources || [];", " entry._parkedNoFade = false;", " if (typeof entry._firstHideWarmupDone !== \"boolean\") entry._firstHideWarmupDone = false;", " this._removeFromList(this.staticEntries, entry, false);", @@ -1641,6 +1854,7 @@ " fadeEnd: entryData.fadeEnd,", " fadeEnabled: !!entryData.fadeEnabled,", " fadeBehavior: entryData.fadeBehavior || null,", + " gradientSources: entryData.gradientSources || [],", " _parkedNoFade: false,", " _firstHideWarmupDone: false", " };", @@ -1652,6 +1866,41 @@ " return entry;", " }", "", + " /** @returns {void} */", + " syncGradientBounds() {", + " this._syncGradientList(this.fadeEntries);", + " this._syncGradientList(this.staticEntries);", + " }", + "", + " /** @param {FoliageNonInstancedEntry[]} entries @returns {void} */", + " _syncGradientList(entries) {", + " for (var entry of entries) {", + " if (!entry.threeObj || !entry.material || !entry.gradientSources || !entry.gradientSources.length) continue;", + " var cfg = entry.material.userData.foliageConfig;", + " if (!cfg || !cfg.gradientBounds) continue;", + " entry.threeObj.updateWorldMatrix(true, true);", + " if (!refreshGradientBounds(entry.gradientSources, cfg.gradientBounds, this.state._frameCtx ? this.state._frameCtx.inverseWorldScale : 1)) continue;", + " this._syncGradientMaterial(entry.material, cfg.gradientBounds);", + " this._syncGradientMaterial(entry.material.userData._foliageDepthMat, cfg.gradientBounds);", + " this._syncGradientMaterial(entry.material.userData._foliageDistanceMat, cfg.gradientBounds);", + " }", + " }", + "", + " /** @param {THREE.Material|null|undefined} material @param {{ localMin: number, localMax: number, worldMin: number, worldMax: number }} bounds @returns {void} */", + " _syncGradientMaterial(material, bounds) {", + " if (!material) return;", + " var cfg = material.userData.foliageConfig;", + " if (!cfg) return;", + " cfg.gradientBounds = bounds;", + " cfg.gradientLocalZMin = cfg.ignoreUV ? bounds.worldMin : bounds.localMin;", + " cfg.gradientLocalZMax = cfg.ignoreUV ? bounds.worldMax : bounds.localMax;", + " var uniforms = material.userData.foliageUniforms;", + " if (uniforms) {", + " if (uniforms.uGradientLocalZMin) uniforms.uGradientLocalZMin.value = cfg.gradientLocalZMin;", + " if (uniforms.uGradientLocalZMax) uniforms.uGradientLocalZMax.value = cfg.gradientLocalZMax;", + " }", + " }", + "", " /**", " * @param {FoliageNonInstancedEntry|null|undefined} entry", " * @returns {FoliageNonInstancedEntry|null}", @@ -2104,6 +2353,7 @@ " g.centersXY = null;", " g.centersZ = null;", " g.cullRadii = null;", + " g.gradientBounds = null;", " g.instanceFade = null;", " g.instanceFadePrev = null;", " g._srcGeometryRef = null;", @@ -2367,6 +2617,7 @@ " sideKey", " ].join(\"|\");", " };", + " this.state.syncMaterialGustDefine(targetMat);", " targetMat.needsUpdate = true;", " return targetMat;", " }", @@ -2771,6 +3022,36 @@ " }", "", " /**", + " * Store mesh-relative world Z bounds in scene units, independent of renderer scale.", + " * @param {FoliageInstancingGroup} group", + " * @param {number} idx", + " * @param {THREE.Matrix4} worldMat", + " * @param {THREE.BufferGeometry} geometry", + " * @param {number} inverseWorldScale", + " */", + " writeInstanceGradientBounds(group, idx, worldMat, geometry, inverseWorldScale) {", + " var required = (idx + 1) * 2;", + " if (!group.gradientBounds || group.gradientBounds.length < required) {", + " var previous = group.gradientBounds;", + " group.gradientBounds = new Float32Array(Math.max(required, previous ? previous.length * 2 : 512));", + " if (previous) group.gradientBounds.set(previous);", + " }", + " if (!geometry.boundingBox) geometry.computeBoundingBox();", + " var box = geometry.boundingBox;", + " var lo = 0, hi = 0;", + " if (box) {", + " var e = worldMat.elements;", + " var x0 = e[2] * box.min.x, x1 = e[2] * box.max.x;", + " var y0 = e[6] * box.min.y, y1 = e[6] * box.max.y;", + " var z0 = e[10] * box.min.z, z1 = e[10] * box.max.z;", + " lo = (Math.min(x0, x1) + Math.min(y0, y1) + Math.min(z0, z1)) / inverseWorldScale;", + " hi = (Math.max(x0, x1) + Math.max(y0, y1) + Math.max(z0, z1)) / inverseWorldScale;", + " }", + " group.gradientBounds[idx * 2] = isFinite(lo) && hi > lo ? lo : -0.5;", + " group.gradientBounds[idx * 2 + 1] = isFinite(hi) && hi > lo ? hi : 0.5;", + " }", + "", + " /**", " * @param {THREE.Material|null|undefined} mat", " * @param {number} determinant", " * @returns {void}", @@ -2905,6 +3186,8 @@ " localM = new THREE.Matrix4();", " inst._tmpMat4_local = localM;", " }", + " var leavesWorld = new THREE.Matrix4();", + " var leavesLocal = new THREE.Matrix4();", "", " var foliageRootMWInv = inst._tmpMat4_foliageRootInv;", " if (!foliageRootMWInv) {", @@ -3006,11 +3289,17 @@ "", " localM.multiplyMatrices(foliageRootMWInv, repW2P);", " var detSignParts = this._getDetSignFromMatrix(localM);", - " var detSuffixParts = this._getDetSignSuffix(detSignParts);", + " var leavesRel = this.getOrComputeRepRelMatrix(gdObjParts, part1.repMesh);", + " if (leavesRel) leavesWorld.multiplyMatrices(objW2P, leavesRel);", + " else leavesWorld.copy(part1.repMesh.matrixWorld);", + " leavesLocal.multiplyMatrices(foliageRootMWInv, leavesWorld);", + " var detSignLeaves = this._getDetSignFromMatrix(leavesLocal);", + " // Both groups share slot indices, so partition by the pair of signs.", + " var detSuffixParts = \"::PAIR_T\" + detSignParts + \"_L\" + detSignLeaves;", " var groupKeyTrunk = part0.baseGroupKey + detSuffixParts;", " var groupKeyLeaves = part1.baseGroupKey + detSuffixParts;", " var materialTrunk = this._getSignedInstancedMaterial(part0.material, detSignParts) || part0.material;", - " var materialLeaves = this._getSignedInstancedMaterial(part1.material, detSignParts) || part1.material;", + " var materialLeaves = this._getSignedInstancedMaterial(part1.material, detSignLeaves) || part1.material;", " this._logDetSignDiagnostic(\"split\", part0.baseGroupKey, detSignParts, behaviorParts);", " var gTrunk = inst.ensureGroup(groupKeyTrunk, {", " key: groupKeyTrunk,", @@ -3034,7 +3323,7 @@ " material: materialLeaves,", " parent: inst.foliageRoot", " });", - " gLeaves.instanceDetSign = detSignParts;", + " gLeaves.instanceDetSign = detSignLeaves;", " if (!gLeaves.freeIndices || gLeaves.freeIndices !== gTrunk.freeIndices) gLeaves.freeIndices = gTrunk.freeIndices;", " if (!gLeaves.freeIndexSet || gLeaves.freeIndexSet !== gTrunk.freeIndexSet) gLeaves.freeIndexSet = gTrunk.freeIndexSet;", " gLeaves.castShadow = !!gLeaves.castShadow || !!(part1.repMesh && part1.repMesh.castShadow);", @@ -3105,10 +3394,10 @@ " gLeaves.matricesBuffer = newBufferL;", " }", " for (var mL = 0; mL < 16; mL++) {", - " gLeaves.matricesBuffer[offsetP + mL] = srcP[mL];", + " gLeaves.matricesBuffer[offsetP + mL] = leavesLocal.elements[mL];", " }", " var cullSphereLeaves = this.computeWorldCullSphere(", - " repW2P,", + " leavesWorld,", " part1.geometry,", " gLeaves._instanceCullRadius,", " state._tmpInstanceCullSphereB || (state._tmpInstanceCullSphereB = { cx: 0, cy: 0, cz: 0, radius: 0 })", @@ -3120,6 +3409,7 @@ " );", " this.writeInstanceCullValues(gTrunk, idxP, cullSphereShared.cx, cullSphereShared.cy, cullSphereShared.cz, cullSphereShared.radius);", " this.writeInstanceCullValues(gLeaves, idxP, cullSphereShared.cx, cullSphereShared.cy, cullSphereShared.cz, cullSphereShared.radius);", + " this.writeInstanceGradientBounds(gLeaves, idxP, leavesWorld, part1.geometry, ctx.inverseWorldScale);", " if (gLeaves.instanceFade && idxP < gLeaves.instanceFade.length) gLeaves.instanceFade[idxP] = 1.0;", " if (gLeaves.instanceFadePrev && idxP < gLeaves.instanceFadePrev.length) gLeaves.instanceFadePrev[idxP] = 1.0;", "", @@ -3277,6 +3567,7 @@ " }", "", " this.writeInstanceCullData(g, idx, repW2, geometry, g._instanceCullRadius);", + " this.writeInstanceGradientBounds(g, idx, repW2, geometry, ctx.inverseWorldScale);", " if (g.instanceFade && idx < g.instanceFade.length) g.instanceFade[idx] = 1.0;", " if (g.instanceFadePrev && idx < g.instanceFadePrev.length) g.instanceFadePrev[idx] = 1.0;", "", @@ -3420,6 +3711,10 @@ " for (var fpi = 0; fpi < newCap; fpi++) fadePrevAttr.array[fpi] = 1.0;", " mesh.geometry.setAttribute(\"aFadePrev\", fadePrevAttr);", "", + " var gradientAttr = new THREE.InstancedBufferAttribute(new Float32Array(newCap * 2), 2);", + " gradientAttr.setUsage(THREE.DynamicDrawUsage);", + " mesh.geometry.setAttribute(\"aFoliageGradientBounds\", gradientAttr);", + "", " mesh.count = 0;", " mesh.visible = false;", " var attachParent = g.parent || inst.foliageRoot;", @@ -3541,10 +3836,12 @@ " var prevCamY = isFinite(this._prevFrustumPassCamY) ? this._prevFrustumPassCamY : camY;", " var prevCamZ = isFinite(this._prevFrustumPassCamZ) ? this._prevFrustumPassCamZ : camZ;", " var camDelta = Math.abs(camX - prevCamX) + Math.abs(camY - prevCamY) + Math.abs(camZ - prevCamZ);", - " var camMoved = camDelta > 0.5;", - " this._prevFrustumPassCamX = camX;", - " this._prevFrustumPassCamY = camY;", - " this._prevFrustumPassCamZ = camZ;", + " var camMoved = !isFinite(this._prevFrustumPassCamX) || camDelta > 0.5 * frameCam.inverseWorldScale;", + " if (camMoved) {", + " this._prevFrustumPassCamX = camX;", + " this._prevFrustumPassCamY = camY;", + " this._prevFrustumPassCamZ = camZ;", + " }", "", " var dirChanged = false;", " if (typeof cam.getWorldDirection === \"function\" && typeof THREE.Vector3 !== \"undefined\") {", @@ -3576,6 +3873,7 @@ " var inst = state.instancing;", " if (!inst || !inst.groups) return false;", "", + " var inverseWorldScale = ctx ? ctx.inverseWorldScale : 1;", " var cam = ctx && ctx.cam ? ctx.cam : null;", " var camX = ctx && isFinite(ctx.camX) ? ctx.camX : 0;", " var camY = ctx && isFinite(ctx.camY) ? ctx.camY : 0;", @@ -3589,10 +3887,7 @@ " var prevCamY = isFinite(this._prevCullCamY) ? this._prevCullCamY : camY;", " var prevCamZ = isFinite(this._prevCullCamZ) ? this._prevCullCamZ : camZ;", " var camDelta = Math.abs(camX - prevCamX) + Math.abs(camY - prevCamY) + Math.abs(camZ - prevCamZ);", - " var camMoved = camDelta > 0.5;", - " this._prevCullCamX = camX;", - " this._prevCullCamY = camY;", - " this._prevCullCamZ = camZ;", + " var camMoved = !isFinite(this._prevCullCamX) || camDelta > 0.5 * inverseWorldScale;", "", " var camDirChanged = false;", " if (cam && typeof cam.getWorldDirection === \"function\" && typeof THREE.Vector3 !== \"undefined\") {", @@ -3618,8 +3913,8 @@ " refreshFadeState(g, g._fadeBehavior || null, false);", " }", " var sigEnabled = !!g.fadeEnabled;", - " var sigStart = isFinite(g.fadeStart) ? Math.round(g.fadeStart) : 1200;", - " var sigEnd = isFinite(g.fadeEnd) ? Math.round(g.fadeEnd) : 1600;", + " var sigStart = isFinite(g.fadeStart) ? g.fadeStart : 1200;", + " var sigEnd = isFinite(g.fadeEnd) ? g.fadeEnd : 1600;", " var newSig = (sigEnabled ? 1 : 0) + \"|\" + sigStart + \"|\" + sigEnd;", " if (g._fadeSig !== newSig) {", " g._fadeSig = newSig;", @@ -3633,6 +3928,11 @@ " return false;", " }", "", + " // Accumulate slow movement against the last processed distance tick.", + " this._prevCullCamX = camX;", + " this._prevCullCamY = camY;", + " this._prevCullCamZ = camZ;", + "", " inst.groups.forEach(function(group) {", " /** @type {FoliageInstancingGroup} */", " var g = group;", @@ -3706,13 +4006,13 @@ "", " var freeSet = g.freeIndexSet;", " var totalSlots = g.matrixCount || 0;", - " var startD = g.fadeStart || 1200;", - " var endD = g.fadeEnd || 1600;", + " var startD = (isFinite(g.fadeStart) ? g.fadeStart : 1200) * inverseWorldScale;", + " var endD = (isFinite(g.fadeEnd) ? g.fadeEnd : 1600) * inverseWorldScale;", " var range = endD - startD;", - " if (range <= 0) range = 100;", + " if (range <= 0) range = 100 * inverseWorldScale;", " var invRange = 1.0 / range;", " var startDSq = startD * startD;", - " var margin = 256;", + " var margin = 256 * inverseWorldScale;", " var endPlusMargin = endD + margin;", " var endPlusMarginSq = endPlusMargin * endPlusMargin;", "", @@ -3823,7 +4123,7 @@ " tmpSphereInstancedCull = this._tmpSphereInstancedCull;", " }", " var frustumRadiusScale = 1.10;", - " var frustumRadiusMargin = 32.0;", + " var frustumRadiusMargin = 32.0 * (ctx ? ctx.inverseWorldScale : 1);", "", " var anyProcessed = false;", " inst.groups.forEach(function(group) {", @@ -3856,6 +4156,7 @@ " var renderMat = mesh.instanceMatrix ? mesh.instanceMatrix.array : null;", " var renderFadeAttr = mesh.geometry ? mesh.geometry.getAttribute(\"aFade\") : null;", " var renderFadePrevAttr = mesh.geometry ? mesh.geometry.getAttribute(\"aFadePrev\") : null;", + " var renderGradientAttr = mesh.geometry ? mesh.geometry.getAttribute(\"aFoliageGradientBounds\") : null;", " var storCenters = g.centersXY;", " var storCentersZ = g.centersZ;", " var storRadii = g.cullRadii;", @@ -3923,6 +4224,10 @@ "", " if (renderFadeAttr && renderFadeAttr.array) renderFadeAttr.array[visibleCount] = fadeEnabled ? storFade[i] : 1.0;", " if (renderFadePrevAttr && renderFadePrevAttr.array) renderFadePrevAttr.array[visibleCount] = fadeEnabled ? storFadePrev[i] : 1.0;", + " if (renderGradientAttr && g.gradientBounds) {", + " renderGradientAttr.array[visibleCount * 2] = g.gradientBounds[i * 2];", + " renderGradientAttr.array[visibleCount * 2 + 1] = g.gradientBounds[i * 2 + 1];", + " }", " visibleCount++;", " }", "", @@ -3930,6 +4235,7 @@ " mesh.instanceMatrix.needsUpdate = true;", " if (renderFadeAttr) renderFadeAttr.needsUpdate = true;", " if (renderFadePrevAttr) renderFadePrevAttr.needsUpdate = true;", + " if (renderGradientAttr) renderGradientAttr.needsUpdate = true;", " mesh.visible = (visibleCount > 0);", " g.visibleCount = visibleCount;", " });", @@ -3974,6 +4280,7 @@ " }", "", " if (su.uFoliageTime) su.uFoliageTime.value = ctx.time;", + " if (su.uFoliageWorldScale) su.uFoliageWorldScale.value = 1 / ctx.inverseWorldScale;", " if (su.uWindStrength) su.uWindStrength.value = ctx.windStrength;", " if (su.uWindSpeed) su.uWindSpeed.value = ctx.windSpeed;", " if (su.uWindDirection) su.uWindDirection.value.set(ctx.wx, ctx.wy);", @@ -4218,6 +4525,7 @@ " if (!ctx) {", " ctx = cache._frameCtx = {", " runtimeScene: null,", + " inverseWorldScale: 1,", " dt: 0,", " time: 0,", " windStrength: 6,", @@ -4233,6 +4541,9 @@ " };", " }", " ctx.runtimeScene = runtimeScene;", + " // Camera and mesh world positions use renderer units, not editor distances.", + " ctx.inverseWorldScale = typeof runtimeScene.getRenderer3DInverseWorldScale === \"function\"", + " ? runtimeScene.getRenderer3DInverseWorldScale() : 1;", " ctx.frustum = null;", " ctx._frustumBuilt = false;", " ", @@ -4311,6 +4622,7 @@ " this.sharedByKey = new Map();", " this.activeMaterials = new Set();", " this.time = 0;", + " this._inverseWorldScale = 1;", " this.gustVersion = 0;", " this.gustEnabled = false;", " this.gustStrength = 3.0;", @@ -4388,11 +4700,96 @@ " this.ensureGustFallbackTex();", " }", "", + " /**", + " * Rebase renderer-space caches only when GDevelop changes its scene scale.", + " * Instance matrices and gradient attributes stay in scene/local units.", + " * @param {gdjs.RuntimeScene} runtimeScene", + " */", + " syncWorldScale(runtimeScene) {", + " var next = typeof runtimeScene.getRenderer3DInverseWorldScale === \"function\"", + " ? runtimeScene.getRenderer3DInverseWorldScale() : 1;", + " if (!isFinite(next) || next <= 0 || next === this._inverseWorldScale) return;", + " var ratio = next / this._inverseWorldScale;", + " this._inverseWorldScale = next;", + " var inst = this.instancingState;", + " inst._repRelByMesh = new WeakMap();", + " inst._repRelMatrixCache.clear();", + " for (const group of inst.groups.values()) {", + " var count = group.matrixCount || 0;", + " for (var i = 0; i < count; i++) {", + " if (group.centersXY) {", + " group.centersXY[i * 2] *= ratio;", + " group.centersXY[i * 2 + 1] *= ratio;", + " }", + " if (group.centersZ) group.centersZ[i] *= ratio;", + " if (group.cullRadii) group.cullRadii[i] *= ratio;", + " }", + " }", + " var seenStats = new Set();", + " for (const entry of this.objectTypeCacheRegistry.entries.values()) {", + " for (const statsMap of [entry.statsByRef, entry.statsByName]) {", + " if (!statsMap) continue;", + " for (const stats of statsMap.values()) {", + " if (seenStats.has(stats)) continue;", + " seenStats.add(stats);", + " stats.relZMin *= ratio;", + " stats.relZMax *= ratio;", + " stats.sizeWorldMin.multiplyScalar(ratio);", + " stats.sizeWorldMax.multiplyScalar(ratio);", + " }", + " }", + " }", + " var materials = new Set(this.activeMaterials);", + " for (const shared of this.sharedByKey.values()) materials.add(shared.material);", + " var seenBounds = new Set();", + " for (const mat of materials) {", + " if (!mat || !mat.userData) continue;", + " if (mat.userData._foliageDepthMat) materials.add(mat.userData._foliageDepthMat);", + " if (mat.userData._foliageDistanceMat) materials.add(mat.userData._foliageDistanceMat);", + " var cfg = mat.userData.foliageConfig;", + " if (!cfg || !cfg.gradientBounds) continue;", + " var bounds = cfg.gradientBounds;", + " if (!seenBounds.has(bounds)) {", + " seenBounds.add(bounds);", + " bounds.worldMin *= ratio;", + " bounds.worldMax *= ratio;", + " }", + " cfg.gradientLocalZMin = cfg.ignoreUV ? bounds.worldMin : bounds.localMin;", + " cfg.gradientLocalZMax = cfg.ignoreUV ? bounds.worldMax : bounds.localMax;", + " var u = mat.userData.foliageUniforms;", + " if (u) {", + " if (u.uFoliageWorldScale) u.uFoliageWorldScale.value = 1 / next;", + " if (u.uGradientLocalZMin) u.uGradientLocalZMin.value = cfg.gradientLocalZMin;", + " if (u.uGradientLocalZMax) u.uGradientLocalZMax.value = cfg.gradientLocalZMax;", + " }", + " }", + " var renderer = runtimeScene.getLayer(\"\").getRenderer();", + " var scene = renderer.getThreeScene ? renderer.getThreeScene() : null;", + " if (scene) scene.updateWorldMatrix(true, true);", + " this.cullingCoordinator.reset();", + " this._fadeParamsDirty = true;", + " }", + "", " resetMaterialBuckets() { return this.materialRegistry.resetBuckets(); }", " registerActiveMaterial(mat) { return this.materialRegistry.registerActiveMaterial(mat); }", " unregisterActiveMaterial(mat) { return this.materialRegistry.unregisterActiveMaterial(mat); }", " disposeShadowMaterials(mat) { return this.materialRegistry.disposeShadowMaterials(mat); }", " markGustForRecompile() { return this.materialRegistry.markGustForRecompile(); }", + " /**", + " * Set the gust define before Three.js chooses a program, not in onBeforeCompile.", + " * @param {THREE.Material|null|undefined} mat", + " */", + " syncMaterialGustDefine(mat) {", + " if (!mat || !mat.userData || !mat.userData.foliageConfig || !mat.userData.foliageConfig.swayType) return;", + " var material = /** @type {THREE.Material & { defines?: Record }} */ (mat);", + " var enabled = !!this.gustEnabled;", + " var hasGust = !!material.defines && Object.prototype.hasOwnProperty.call(material.defines, \"USE_GUST\");", + " if (hasGust === enabled) return;", + " material.defines = Object.assign({}, material.defines || {});", + " if (enabled) material.defines.USE_GUST = \"\";", + " else delete material.defines.USE_GUST;", + " material.needsUpdate = true;", + " }", " syncObjectHotReload(gdObj, behavior, behaviorName) { return syncObjectHotReload.call(this, gdObj, behavior, behaviorName); }", " _syncObjectTypeCacheAliases() {", " var registry = this.objectTypeCacheRegistry;", @@ -4443,6 +4840,7 @@ " if (!scene._foliageSwaying3D) {", " scene._foliageSwaying3D = createSceneState(runtimeScene);", " }", + " scene._foliageSwaying3D.syncWorldScale(runtimeScene);", " return scene._foliageSwaying3D;", " }", " ", @@ -4463,6 +4861,7 @@ " isAlphaLikely: isAlphaLikely,", " scoreMaterial: scoreMaterial,", " sanitizeKeyPart: sanitizeKeyPart,", + " refreshGradientBounds: refreshGradientBounds,", " meshMatchesSelection: meshMatchesSelection,", " findFirstMatchingMesh: findFirstMatchingMesh,", " findSplitTreeMeshes: findSplitTreeMeshes,", @@ -4513,12 +4912,12 @@ " /** @typedef {{ swayResponse: number, swayResponseRaw: number, sizeFactor: number, complexityFactor: number, vertexFactor: number, triFactor: number, structureFactor: number, planeFactor: number, responseGain: number, planeLikeRatio: number, baseByType: number, boundsMode: string }} FoliageAutoSwayResponseEntryBridge */", " /** @typedef {{ key?: string, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|null, parent?: THREE.Object3D|null, matricesBuffer?: Float32Array|null, centersXY?: Float32Array|null, centersZ?: Float32Array|null, cullRadii?: Float32Array|null, matrixCount?: number, aliveCount?: number, freeIndices?: number[], freeIndexSet?: Set, capacity?: number, mesh?: THREE.InstancedMesh|null, castShadow?: boolean, receiveShadow?: boolean, fadeEnabled?: boolean, fadeStart?: number, fadeEnd?: number, _fadeBehavior?: FoliageBehaviorBridge|null, _fadeSig?: string, _instanceCullRadius?: number, instanceFade?: Float32Array|null, instanceFadePrev?: Float32Array|null, _distanceCandidateIndices?: Uint32Array|null, _distanceCandidateCount?: number, _fadeDisabledApplied?: boolean, _lastFadeEnabled?: boolean, visibleCount?: number, _srcGeometryRef?: THREE.BufferGeometry|null, _ownedGeometry?: THREE.BufferGeometry|null }} FoliageInstancingGroupBridge */", " /** @typedef {{ groups: Map, dirty: boolean, sceneTag: string|null, pending: FoliagePendingItemBridge[], queueIdCounter: number, cancelledQueueIds: Set, foliageRoot?: THREE.Object3D|null, _cachedSceneRoot?: THREE.Object3D|null, _tmpRootSet?: Set|null, _tmpMat4?: THREE.Matrix4, _tmpObj3D?: THREE.Object3D, _tmpVec3_pos?: THREE.Vector3, _tmpVec3_scale?: THREE.Vector3, _tmpEuler?: THREE.Euler, _tmpQuat?: THREE.Quaternion, _tmpMat4_objWorld?: THREE.Matrix4, _tmpMat4_objWorldInv?: THREE.Matrix4, _tmpMat4_repWorld?: THREE.Matrix4, _repRelByMesh?: WeakMap, _itemsByParentRepMesh?: Map, _repRelMatrixCache?: Map, _tmpMat4_local?: THREE.Matrix4, _tmpMat4_foliageRootInv?: THREE.Matrix4, ensureReady: () => FoliageInstancingStateBridge, markDirty: () => FoliageInstancingStateBridge }} FoliageInstancingStateBridge */", - " /** @typedef {{ fadeEntries: FoliageNonInstancedEntryBridge[], staticEntries: FoliageNonInstancedEntryBridge[], staticCheckAccum: number, removeInvalidEntry: (entry: FoliageNonInstancedEntryBridge|null|undefined, fromList: \"fade\"|\"static\") => boolean, parkEntry: (entry: FoliageNonInstancedEntryBridge|null|undefined) => FoliageNonInstancedEntryBridge|null, reactivateEntry: (entry: FoliageNonInstancedEntryBridge|null|undefined) => FoliageNonInstancedEntryBridge|null }} FoliageNonInstancedRegistryBridge */", + " /** @typedef {{ syncGradientBounds: () => void, fadeEntries: FoliageNonInstancedEntryBridge[], staticEntries: FoliageNonInstancedEntryBridge[], staticCheckAccum: number, removeInvalidEntry: (entry: FoliageNonInstancedEntryBridge|null|undefined, fromList: \"fade\"|\"static\") => boolean, parkEntry: (entry: FoliageNonInstancedEntryBridge|null|undefined) => FoliageNonInstancedEntryBridge|null, reactivateEntry: (entry: FoliageNonInstancedEntryBridge|null|undefined) => FoliageNonInstancedEntryBridge|null }} FoliageNonInstancedRegistryBridge */", " /** @typedef {{ timeWind: Set, fadeInterp: Set, gust: Set, shadowHost: Set, unknown: Set, rebuildNeeded: boolean }} FoliageMaterialBucketsBridge */", " /** @typedef {{ flushPending: (ctx: SharedFrameContextBridge) => void, rebuildDirtyGroups: () => void }} FoliageInstancingCoordinatorBridge */", " /** @typedef {{ reset: () => FoliageCullingCoordinatorBridge, didFrustumCameraChange: (frameCam: SharedFrameContextBridge|null|undefined) => boolean, performDistanceCullTick: (ctx: SharedFrameContextBridge|null|undefined, forceRun: boolean, refreshFadeState: (target: FoliageFadeStateTargetBridge|null|undefined, behavior: FoliageBehaviorBridge|null|undefined, defaultEnabled: boolean) => void) => boolean, performFrustumCompactionPass: (ctx: SharedFrameContextBridge|null|undefined) => boolean }} FoliageCullingCoordinatorBridge */", " /** @typedef {{ syncMaterial: (hostMat: THREE.Material|null|undefined, shadowMat: THREE.Material|null|undefined, ctx: SharedFrameContextBridge, fadeInterpT: number) => void, syncShadowHostBucket: (bucket: Set|null|undefined, activeMaterials: Set|null|undefined, unregisterActiveMaterial: (mat: THREE.Material|null|undefined) => void, ctx: SharedFrameContextBridge, fadeInterpT: number) => void }} FoliageShadowUniformSyncBridge */", - " /** @typedef {{ runtimeScene: gdjs.RuntimeScene|null, dt: number, time: number, windStrength: number, windSpeed: number, wx: number, wy: number, cam: THREE.Camera|null, camX: number, camY: number, camZ: number, frustum: THREE.Frustum|null, _frustumBuilt: boolean }} SharedFrameContextBridge */", + " /** @typedef {{ runtimeScene: gdjs.RuntimeScene|null, inverseWorldScale: number, dt: number, time: number, windStrength: number, windSpeed: number, wx: number, wy: number, cam: THREE.Camera|null, camX: number, camY: number, camZ: number, frustum: THREE.Frustum|null, _frustumBuilt: boolean }} SharedFrameContextBridge */", " /** @typedef {{ _libReady?: boolean, time: number, gustVersion: number, fadeUpdateHz?: number, cullAccum?: number, cullInterval?: number, _lastLoggedFadeHz?: number, lastCullTime?: number, _lastFadeUpdateHz?: number, lastCamX?: number, lastCamY?: number, lastCamZ?: number, _fadeParamsDirty?: boolean, nonInstancedFadeObjects: FoliageNonInstancedEntryBridge[], nonInstancedStaticObjects: FoliageNonInstancedEntryBridge[], nonInstancedRegistry: FoliageNonInstancedRegistryBridge, autoSwayResponseCache: Map, _nonInstancedStaticCheckAccum: number, _readBoolArg: (efc: FoliageEventsFunctionContextBridge, name: string, fallback: boolean) => boolean, _readNumArg: (efc: FoliageEventsFunctionContextBridge, name: string, fallback: number) => number, _readStringArg: (efc: FoliageEventsFunctionContextBridge, name: string, fallback: string) => string, _gustDefineDirty?: boolean, markGustForRecompile: () => void, instancing: FoliageInstancingStateBridge, instancingState: FoliageInstancingStateBridge, instancingCoordinator: FoliageInstancingCoordinatorBridge, cullingCoordinator: FoliageCullingCoordinatorBridge, shadowUniformSync: FoliageShadowUniformSyncBridge, cleanupForSceneChange: () => void, buildFrameContext: (runtimeScene: gdjs.RuntimeScene, eventsFunctionContext: FoliageEventsFunctionContextBridge) => SharedFrameContextBridge, activeMaterials: Set, _materialBuckets: FoliageMaterialBucketsBridge, resetMaterialBuckets: () => void, unregisterActiveMaterial: (mat: THREE.Material|null|undefined) => void, disposeShadowMaterials: (mat: THREE.Material|null|undefined) => void, sharedByKey: Map, _ensureFrustum: (ctx: SharedFrameContextBridge) => THREE.Frustum|null, gustEnabled: boolean, gustStrength: number, gustScale: number, gustSpeed: number, gustThreshold: number, gustContrast: number, gustTexture: THREE.Texture|null, gustFallbackTex: THREE.Texture|null, _tmpVec3NonInstancedFade?: THREE.Vector3|null, getBehaviorProps: (behavior: FoliageBehaviorBridge|null|undefined) => FoliageBehaviorFadePropsBridge|null }} FoliageSceneStateBridge */", " /** @typedef {{ _libReady?: boolean, getSceneState: (runtimeScene: gdjs.RuntimeScene) => FoliageSceneStateBridge }} FoliageExtensionBridge */", " /** @typedef {typeof gdjs & { _foliageSwaying3D?: FoliageExtensionBridge }} FoliageGdjsBridge */", @@ -4660,6 +5059,8 @@ " instancingOps.flushPending(ctx);", " var hadDirty = cache.instancing && cache.instancing.dirty;", " instancingOps.rebuildDirtyGroups();", + " // Includes fade-disabled objects and final transforms applied after onCreated.", + " cache.nonInstancedRegistry.syncGradientBounds();", " ", " var arrNi = cache.nonInstancedFadeObjects;", " var arrNiLen = (arrNi && Array.isArray(arrNi)) ? arrNi.length : 0;", @@ -4755,7 +5156,8 @@ " var camX = frameCam && isFinite(frameCam.camX) ? frameCam.camX : 0;", " var camY = frameCam && isFinite(frameCam.camY) ? frameCam.camY : 0;", " var tmpVecNi = (cache.instancing && cache.instancing._tmpVec3_pos) ? cache.instancing._tmpVec3_pos : (cache._tmpVec3NonInstancedFade || (cache._tmpVec3NonInstancedFade = new THREE.Vector3()));", - " var marginNi = 256.0;", + " var inverseWorldScaleNi = ctx.inverseWorldScale;", + " var marginNi = 256.0 * inverseWorldScaleNi;", " // Backward loop: swap-remove invalid entries in-place (O(1) per removal, order not guaranteed)", " for (var ni = arrNiLen - 1; ni >= 0; ni--) {", " var entryNi = arrNi[ni];", @@ -4793,6 +5195,8 @@ " try {", " objX = entryNi.gdObj.getX ? entryNi.gdObj.getX() : 0;", " objY = entryNi.gdObj.getY ? entryNi.gdObj.getY() : 0;", + " objX *= inverseWorldScaleNi;", + " objY *= -inverseWorldScaleNi;", " tmpVecNi.set(objX, objY, 0);", " posOk = true;", " } catch (eGd) {}", @@ -4805,6 +5209,8 @@ " var startNi = entryNi.fadeStart != null ? entryNi.fadeStart : 1200;", " var endNi = entryNi.fadeEnd != null ? entryNi.fadeEnd : 1600;", " if (endNi <= startNi) endNi = startNi + 100;", + " startNi *= inverseWorldScaleNi;", + " endNi *= inverseWorldScaleNi;", " var dxNi = objX - camX;", " var dyNi = objY - camY;", " var distSqNi = dxNi * dxNi + dyNi * dyNi;", @@ -4812,7 +5218,7 @@ " var endNiWithMargin = endNi + marginNi;", " var endNiWithMarginSq = endNiWithMargin * endNiWithMargin;", " var rangeNi = endNi - startNi;", - " if (rangeNi <= 0) rangeNi = 100;", + " if (rangeNi <= 0) rangeNi = 100 * inverseWorldScaleNi;", " var invRangeNi = 1.0 / rangeNi;", " var fadeNi;", " if (distSqNi > endNiWithMarginSq) {", @@ -4902,6 +5308,10 @@ " }", " var uUnknown = udUnknown.foliageUniforms;", " if (!uUnknown) continue;", + " // Recompilation can remove uniforms as well as add them.", + " buckets.timeWind.delete(matUnknown);", + " buckets.fadeInterp.delete(matUnknown);", + " buckets.gust.delete(matUnknown);", " if (uUnknown.uFoliageTime || uUnknown.uWindStrength || uUnknown.uWindSpeed || uUnknown.uWindDirection || uUnknown.uLocalWindDirection || uUnknown.uLocalWindPerpendicular) {", " buckets.timeWind.add(matUnknown);", " }", @@ -4924,9 +5334,11 @@ " continue;", " }", " var uTW = udTW.foliageUniforms;", + " if (uTW.uFoliageWorldScale) uTW.uFoliageWorldScale.value = 1 / ctx.inverseWorldScale;", " var last = udTW._lastUniforms;", - " if (!last) {", + " if (!last || last.uniforms !== uTW) {", " last = udTW._lastUniforms = {", + " uniforms: uTW,", " time: undefined,", " windStrength: undefined,", " windSpeed: undefined,", @@ -5018,8 +5430,7 @@ " ctx,", " fadeInterpT", " );", - " ", - " ", + "", "" ], "parameterObjects": "", @@ -6202,23 +6613,29 @@ " /** @typedef {{ material: THREE.Material, refCount: number, _ownedByFoliage?: boolean }} FoliageSharedMaterialEntryBridge */", " /** @typedef {THREE.Material & { alphaMap?: THREE.Texture|null, map?: THREE.Texture|null, alphaTest?: number, transparent?: boolean, opacity?: number, name?: string }} FoliageInspectableMaterialBridge */", " /** @typedef {{ swayResponse: number, swayResponseRaw: number, sizeFactor: number, complexityFactor: number, vertexFactor: number, triFactor: number, structureFactor: number, planeFactor: number, responseGain: number, planeLikeRatio: number, baseByType: number, boundsMode: string }} FoliageAutoSwayResponseEntryBridge */", - " /** @typedef {{ _cachedGeometry?: THREE.BufferGeometry|null, _gpuGeometry?: THREE.BufferGeometry|null, _gpuGroupKey?: string, _sharedMaterialKey?: string, _resolvedSide?: THREE.Side, _gpuFastMode?: \"single\"|\"splitLeavesTree\", _gpuLeavesGeometry?: THREE.BufferGeometry|null, _gpuTrunkGeometry?: THREE.BufferGeometry|null, _gpuLeavesBaseGroupKey?: string, _gpuTrunkBaseGroupKey?: string, _gpuLeavesSharedMaterialKey?: string, _gpuTrunkSharedMaterialKey?: string, _gpuLeavesResolvedSide?: THREE.Side, _gpuTrunkResolvedSide?: THREE.Side, records?: FoliageMaterialRecordBridge[], statsByRef?: Map, statsByName?: Map, selection?: FoliageMaterialSelectionBridge|null }} FoliageObjectTypeCacheEntryBridge */", + " /** @typedef {{ mesh: FoliageMeshLike, bounds: THREE.Box3, zRow: number[] }} FoliageGradientSourceBridge */", + " /** @typedef {{ _cachedGeometry?: THREE.BufferGeometry|null, _gpuSourceGeometry?: THREE.BufferGeometry|null, _gpuGeometry?: THREE.BufferGeometry|null, _gpuGroupKey?: string, _sharedMaterialKey?: string, _resolvedSide?: THREE.Side, _gpuFastMode?: \"single\"|\"splitLeavesTree\", _gpuLeavesSourceGeometry?: THREE.BufferGeometry|null, _gpuTrunkSourceGeometry?: THREE.BufferGeometry|null, _gpuLeavesGeometry?: THREE.BufferGeometry|null, _gpuTrunkGeometry?: THREE.BufferGeometry|null, _gpuLeavesBaseGroupKey?: string, _gpuTrunkBaseGroupKey?: string, _gpuLeavesSharedMaterialKey?: string, _gpuTrunkSharedMaterialKey?: string, _gpuLeavesResolvedSide?: THREE.Side, _gpuTrunkResolvedSide?: THREE.Side, records?: FoliageMaterialRecordBridge[], statsByRef?: Map, statsByName?: Map, selection?: FoliageMaterialSelectionBridge|null }} FoliageObjectTypeCacheEntryBridge */", " /** @typedef {{ repMesh?: THREE.Object3D|null, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|null, baseGroupKey?: string }} FoliagePendingPartBridge */", " /** @typedef {{ gdObj?: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string })|null, threeObj?: THREE.Object3D|null, repMesh?: THREE.Object3D|null, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|null, parent?: THREE.Object3D|null, baseGroupKey?: string, groupKey?: string, swayType?: string, behavior?: FoliageBehaviorBridge|null, queueId?: number, objectName?: string, behaviorName?: string, parts?: FoliagePendingPartBridge[] }} FoliagePendingItemBridge */", " /** @typedef {{ key?: string, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|null, parent?: THREE.Object3D|null, matricesBuffer?: Float32Array|null, centersXY?: Float32Array|null, matrixCount?: number, aliveCount?: number, freeIndices?: number[], freeIndexSet?: Set, capacity?: number, mesh?: THREE.InstancedMesh|null, centersZ?: Float32Array|null, cullRadii?: Float32Array|null, fadeEnabled?: boolean, fadeStart?: number, fadeEnd?: number, instanceFade?: Float32Array|null, instanceFadePrev?: Float32Array|null }} FoliageInstancingGroupBridge */", " /** @typedef {{ groups: Map, dirty: boolean, sceneTag: string|null, pending: FoliagePendingItemBridge[], queueIdCounter: number, cancelledQueueIds: Set, foliageRoot?: THREE.Object3D|null, _cachedSceneRoot?: THREE.Object3D|null, ensureReady: () => FoliageInstancingStateBridge, nextQueueId: () => number, enqueue: (item: FoliagePendingItemBridge) => FoliagePendingItemBridge, ensureGroup: (groupKey: string, seedData?: FoliageInstancingGroupBridge) => FoliageInstancingGroupBridge, markDirty: () => FoliageInstancingStateBridge }} FoliageInstancingStateBridge */", - " /** @typedef {{ gdObj?: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string })|null, threeObj?: THREE.Object3D|null, material?: THREE.Material|null, trunkMaterial?: THREE.Material|null, fadeStart?: number, fadeEnd?: number, fadeEnabled?: boolean, fadeBehavior?: FoliageBehaviorBridge|null, _wasHidden?: boolean, _parkedNoFade?: boolean, _firstHideWarmupDone?: boolean }} FoliageNonInstancedEntryBridge */", + " /** @typedef {{ gdObj?: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string })|null, threeObj?: THREE.Object3D|null, material?: THREE.Material|null, trunkMaterial?: THREE.Material|null, fadeStart?: number, fadeEnd?: number, fadeEnabled?: boolean, fadeBehavior?: FoliageBehaviorBridge|null, gradientSources?: FoliageGradientSourceBridge[], _wasHidden?: boolean, _parkedNoFade?: boolean, _firstHideWarmupDone?: boolean }} FoliageNonInstancedEntryBridge */", " /** @typedef {{ upsertFadeEntry: (entryData: FoliageNonInstancedEntryBridge) => FoliageNonInstancedEntryBridge|null }} FoliageNonInstancedRegistryBridge */", - " /** @typedef {{ get: (key: string) => FoliageObjectTypeCacheEntryBridge|undefined, set: (key: string, value: FoliageObjectTypeCacheEntryBridge, kindHint?: string) => void, getCachedBBox: (geometry: THREE.BufferGeometry) => THREE.Box3|null|undefined, setCachedBBox: (geometry: THREE.BufferGeometry, bbox: THREE.Box3) => void, getAutoSwayResponse: (key: string) => FoliageAutoSwayResponseEntryBridge|null|undefined, setAutoSwayResponse: (key: string, value: FoliageAutoSwayResponseEntryBridge) => void, hasDebugKey: (key: string) => boolean, markDebugKey: (key: string) => void, resolveOrPick: (root: THREE.Object3D, wantedName: string, sharedKeyForCache: string, helpers: { isAlphaLikely: (mat: FoliageInspectableMaterialBridge|null|undefined) => boolean, scoreMaterial: (mat: FoliageInspectableMaterialBridge|null|undefined) => number }) => FoliageObjectTypeCacheEntryBridge, debugPrintFromRecords: (objName: string, records: FoliageMaterialRecordBridge[]) => void }} FoliageObjectTypeCacheRegistryBridge */", + " /** @typedef {{ get: (key: string) => FoliageObjectTypeCacheEntryBridge|undefined, set: (key: string, value: FoliageObjectTypeCacheEntryBridge, kindHint?: string) => void, collectGradientSources: (root: THREE.Object3D, selection: FoliageMaterialSelectionBridge) => FoliageGradientSourceBridge[], getRenderGeometry: (mesh: FoliageMeshLike, selection: FoliageMaterialSelectionBridge) => THREE.BufferGeometry, supportsInstancing: (root: THREE.Object3D, selection: FoliageMaterialSelectionBridge, swayType: string) => boolean, getCachedBBox: (geometry: THREE.BufferGeometry) => THREE.Box3|null|undefined, setCachedBBox: (geometry: THREE.BufferGeometry, bbox: THREE.Box3) => void, getAutoSwayResponse: (key: string) => FoliageAutoSwayResponseEntryBridge|null|undefined, setAutoSwayResponse: (key: string, value: FoliageAutoSwayResponseEntryBridge) => void, hasDebugKey: (key: string) => boolean, markDebugKey: (key: string) => void, resolveOrPick: (root: THREE.Object3D, wantedName: string, sharedKeyForCache: string, helpers: { isAlphaLikely: (mat: FoliageInspectableMaterialBridge|null|undefined) => boolean, scoreMaterial: (mat: FoliageInspectableMaterialBridge|null|undefined) => number }) => FoliageObjectTypeCacheEntryBridge, debugPrintFromRecords: (objName: string, records: FoliageMaterialRecordBridge[]) => void }} FoliageObjectTypeCacheRegistryBridge */", " /** @typedef {THREE.Object3D & { isMesh?: boolean, geometry?: THREE.BufferGeometry|null, material?: THREE.Material|THREE.Material[]|null, castShadow?: boolean, receiveShadow?: boolean, id?: number, name?: string }} FoliageMeshLike */", - " /** @typedef {{ _libReady?: boolean, getSceneState: (runtimeScene: gdjs.RuntimeScene) => FoliageSceneStateBridge, normalizeCullingMode: (v: FoliagePrimitiveValueBridge) => { name: string, code: number }, resolveRenderSide: (sourceSide: THREE.Side|undefined|null, cullingModeName: string) => THREE.Side, buildSideSuffix: (cullingModeCode: number, renderSide: THREE.Side|undefined|null) => string, buildPbrSuffix: (customPBREnabledValue: boolean, metallicValue: number, roughnessValue: number, specularValue: number, normalStrengthValue: number, ambientOcclusionStrengthValue: number, environmentStrengthValue: number) => string, buildGpuFastCacheKey: (localSharedKey: string, localMaterialName: string, localSwayType: string, localDistanceFadeEnabled: boolean, localPbrSuffix: string, localSideSuffix: string, localTwoSidedLighting: boolean, localSwayResponseAutoMode: boolean) => string, parseColor: (str: string, fallbackHex: string) => THREE.Color, isAlphaLikely: (mat: FoliageInspectableMaterialBridge|null|undefined) => boolean, scoreMaterial: (mat: FoliageInspectableMaterialBridge|null|undefined) => number, sanitizeKeyPart: (s: string) => string, meshMatchesSelection: (mesh: FoliageMeshLike, selection: FoliageMaterialSelectionBridge, srcRef: THREE.Material|null, srcName: string) => boolean, findFirstMatchingMesh: (root: THREE.Object3D, selection: FoliageMaterialSelectionBridge, srcRef: THREE.Material|null, srcName: string) => FoliageMeshLike|null, findSplitTreeMeshes: (root: THREE.Object3D, trunkMatcher: (mesh: FoliageMeshLike) => boolean, leavesMatcher: (mesh: FoliageMeshLike) => boolean) => { trunk: FoliageMeshLike|null, leaves: FoliageMeshLike|null } }} FoliageExtensionBridge */", + " /** @typedef {{ _libReady?: boolean, getSceneState: (runtimeScene: gdjs.RuntimeScene) => FoliageSceneStateBridge, normalizeCullingMode: (v: FoliagePrimitiveValueBridge) => { name: string, code: number }, resolveRenderSide: (sourceSide: THREE.Side|undefined|null, cullingModeName: string) => THREE.Side, buildSideSuffix: (cullingModeCode: number, renderSide: THREE.Side|undefined|null) => string, buildPbrSuffix: (customPBREnabledValue: boolean, metallicValue: number, roughnessValue: number, specularValue: number, normalStrengthValue: number, ambientOcclusionStrengthValue: number, environmentStrengthValue: number) => string, buildGpuFastCacheKey: (localSharedKey: string, localMaterialName: string, localSwayType: string, localDistanceFadeEnabled: boolean, localPbrSuffix: string, localSideSuffix: string, localTwoSidedLighting: boolean, localSwayResponseAutoMode: boolean) => string, parseColor: (str: string, fallbackHex: string) => THREE.Color, isAlphaLikely: (mat: FoliageInspectableMaterialBridge|null|undefined) => boolean, scoreMaterial: (mat: FoliageInspectableMaterialBridge|null|undefined) => number, sanitizeKeyPart: (s: string) => string, refreshGradientBounds: (sources: FoliageGradientSourceBridge[], bounds: { worldMin: number, worldMax: number }, inverseWorldScale: number) => boolean, meshMatchesSelection: (mesh: FoliageMeshLike, selection: FoliageMaterialSelectionBridge, srcRef: THREE.Material|null, srcName: string) => boolean, findFirstMatchingMesh: (root: THREE.Object3D, selection: FoliageMaterialSelectionBridge, srcRef: THREE.Material|null, srcName: string) => FoliageMeshLike|null, findSplitTreeMeshes: (root: THREE.Object3D, trunkMatcher: (mesh: FoliageMeshLike) => boolean, leavesMatcher: (mesh: FoliageMeshLike) => boolean) => { trunk: FoliageMeshLike|null, leaves: FoliageMeshLike|null } }} FoliageExtensionBridge */", " /** @typedef {typeof gdjs & { _foliageSwaying3D?: FoliageExtensionBridge }} FoliageGdjsBridge */", - " /** @typedef {{ _libReady?: boolean, instancingState: FoliageInstancingStateBridge, nonInstancedRegistry: FoliageNonInstancedRegistryBridge, cleanupForSceneChange: () => void, disposeShadowMaterials: (mat: THREE.Material|null|undefined) => void, ensureGustFallbackTex: () => THREE.Texture|null, getBehaviorProps: (behavior: FoliageBehaviorBridge|null|undefined) => FoliageBehaviorPropsBridge|null, gustContrast: number, gustEnabled: boolean, gustFallbackTex: THREE.Texture|null, gustScale: number, gustSpeed: number, gustStrength: number, gustTexture: THREE.Texture|null, gustThreshold: number, objectTypeCacheRegistry: FoliageObjectTypeCacheRegistryBridge, patchedMaterials: WeakSet, registerActiveMaterial: (mat: THREE.Material|null|undefined) => void, sharedByKey: Map, unregisterActiveMaterial: (mat: THREE.Material|null|undefined) => void, _defaultTopColor?: THREE.Color, _defaultBottomColor?: THREE.Color }} FoliageSceneStateBridge */", + " /** @typedef {{ _libReady?: boolean, instancingState: FoliageInstancingStateBridge, nonInstancedRegistry: FoliageNonInstancedRegistryBridge, cleanupForSceneChange: () => void, disposeShadowMaterials: (mat: THREE.Material|null|undefined) => void, ensureGustFallbackTex: () => THREE.Texture|null, getBehaviorProps: (behavior: FoliageBehaviorBridge|null|undefined) => FoliageBehaviorPropsBridge|null, gustContrast: number, gustEnabled: boolean, gustFallbackTex: THREE.Texture|null, gustScale: number, gustSpeed: number, gustStrength: number, gustTexture: THREE.Texture|null, gustThreshold: number, objectTypeCacheRegistry: FoliageObjectTypeCacheRegistryBridge, patchedMaterials: WeakSet, registerActiveMaterial: (mat: THREE.Material|null|undefined) => void, syncMaterialGustDefine: (mat: THREE.Material|null|undefined) => void, _frameCtx?: { time: number, windStrength: number, windSpeed: number, wx: number, wy: number }, sharedByKey: Map, unregisterActiveMaterial: (mat: THREE.Material|null|undefined) => void, _defaultTopColor?: THREE.Color, _defaultBottomColor?: THREE.Color }} FoliageSceneStateBridge */", " var gdjsFoliage = /** @type {FoliageGdjsBridge} */ (gdjs);", " var foliageExt = gdjsFoliage._foliageSwaying3D;", " if (!foliageExt || !foliageExt._libReady) return;", " /** @type {FoliageSceneStateBridge} */", " var cache = foliageExt.getSceneState(runtimeScene);", + " /** @returns {number} */", + " function getFoliageWorldScale() {", + " return typeof runtimeScene.getRenderer3DInverseWorldScale === \"function\"", + " ? 1 / runtimeScene.getRenderer3DInverseWorldScale() : 1;", + " }", " /** @type {FoliageNonInstancedRegistryBridge|null|undefined} */", " var nonInstanced = cache.nonInstancedRegistry;", " if (!nonInstanced) return;", @@ -6328,7 +6745,7 @@ " threeObjFast.traverse(function(o) {", " if (repMeshFast) return;", " var meshFast = /** @type {FoliageMeshLike} */ (o);", - " if (meshFast && meshFast.isMesh && meshFast.geometry === geoFast) {", + " if (meshFast && meshFast.isMesh && meshFast.geometry === fastCached._gpuSourceGeometry) {", " repMeshFast = meshFast;", " }", " });", @@ -6406,10 +6823,12 @@ "\t var splitFastMeshes = findSplitTreeMeshes(", "\t threeObjFastLeaves,", "\t function(mesh) {", - "\t return mesh.geometry === geoFastTrunk;", + "\t return mesh.geometry === fastCachedLeaves._gpuTrunkSourceGeometry &&", + " meshMatchesSelection(mesh, { matchMode: \"name\", matchName: entryFastTrunk.material.name, srcRef: null, pickedId: \"\" }, null, entryFastTrunk.material.name);", "\t },", "\t function(mesh) {", - "\t return mesh.geometry === geoFastLeaves;", + "\t return mesh.geometry === fastCachedLeaves._gpuLeavesSourceGeometry &&", + " meshMatchesSelection(mesh, { matchMode: \"name\", matchName: entryFastLeaves.material.name, srcRef: null, pickedId: \"\" }, null, entryFastLeaves.material.name);", "\t }", "\t );", "\t var repMeshFastTrunk = splitFastMeshes.trunk;", @@ -6559,13 +6978,21 @@ " }", " ", " var selection = rop.selection;", + " if (gpuInstancing && !objectTypeCache.supportsInstancing(threeObj, selection, swayType)) {", + " gpuInstancing = false;", + " var unsupportedKey = sharedKey + \"::UNSUPPORTED_INSTANCING_LAYOUT\";", + " if (debugOutput && !objectTypeCache.hasDebugKey(unsupportedKey)) {", + " objectTypeCache.markDebugKey(unsupportedKey);", + " console.warn(\"[FoliageSwaying3D] Keeping '\" + sharedKey + \"' non-instanced: the current layout supports one mesh/material, or one trunk and one leaves part.\");", + " }", + " }", + "", + "", " ", - " // Calculate bounding box (min/max Z) ONLY for meshes that use the selected material", - " // This ensures gradient works across all polygons with the same material as if they were one mesh", - " // Uses geometry AABB instead of iterating all vertices - much faster! (O(m) instead of O(n log n))", - " // Stats for selected material/name were precomputed during collectAndStats (single traverse)", - " var gradientLocalZMin = Infinity;", - " var gradientLocalZMax = -Infinity;", + " // Keep selected-polygon bounds and mesh references; GDevelop applies scene", + " // transforms after onCreated, so the non-instanced registry refreshes world Z.", + " var gradientSources = objectTypeCache.collectGradientSources(threeObj, selection);", + " var gradientBounds = { localMin: Infinity, localMax: -Infinity, worldMin: Infinity, worldMax: -Infinity };", " var hasGeometry = false;", " ", " // For automatic sway response (size + density). Prefer root-local bounds for stability.", @@ -6597,17 +7024,6 @@ " ", " if (st) {", " hasGeometry = !!st.hasGeom;", - " // World-gradient mode from the IgnoreUV property: use relative world Z (world Z minus mesh origin Z).", - " // GDevelop uses Z-up coordinate system (Z is vertical, Y is depth)", - " // This ensures gradient ALWAYS goes up-down regardless of model orientation", - " // UV mode: use local Z (position.z in shader)", - " if (ignoreUV && isFinite(st.relZMin) && isFinite(st.relZMax) && st.relZMax > st.relZMin) {", - " gradientLocalZMin = st.relZMin;", - " gradientLocalZMax = st.relZMax;", - " } else if (!ignoreUV) {", - " gradientLocalZMin = st.zMin;", - " gradientLocalZMax = st.zMax;", - " }", " totalVerts = st.totalVerts || 0;", " totalTris = st.totalTris || 0;", " meshCountForScale = st.meshCount || 0;", @@ -6619,35 +7035,48 @@ " if (st.sizeWorldMax) sizeWorldMax = st.sizeWorldMax;", " }", " ", + " var localSizeX = (sizeLocalMin && sizeLocalMax) ? sizeLocalMax.x - sizeLocalMin.x : NaN;", + " var localSizeY = (sizeLocalMin && sizeLocalMax) ? sizeLocalMax.y - sizeLocalMin.y : NaN;", + " var localSizeZ = (sizeLocalMin && sizeLocalMax) ? sizeLocalMax.z - sizeLocalMin.z : NaN;", + " // Inverting rotated/scaled matrices can give flat cards a tiny artificial thickness.", + " var localBoundsEpsilon = Math.max(localSizeX, localSizeY, localSizeZ) * 1e-8;", " var useLocalBoundsForAuto =", " !!sizeLocalMin && !!sizeLocalMax &&", " isFinite(sizeLocalMin.x) && isFinite(sizeLocalMin.y) && isFinite(sizeLocalMin.z) &&", " isFinite(sizeLocalMax.x) && isFinite(sizeLocalMax.y) && isFinite(sizeLocalMax.z) &&", - " sizeLocalMax.x > sizeLocalMin.x &&", - " sizeLocalMax.y > sizeLocalMin.y &&", - " sizeLocalMax.z > sizeLocalMin.z;", + " localSizeX > localBoundsEpsilon &&", + " localSizeY > localBoundsEpsilon &&", + " localSizeZ > localBoundsEpsilon;", " ", " var autoBoundsMin = useLocalBoundsForAuto ? sizeLocalMin : sizeWorldMin;", " var autoBoundsMax = useLocalBoundsForAuto ? sizeLocalMax : sizeWorldMax;", - " ", - " // Fallback if no geometry found or invalid bounds", - " if (!hasGeometry || !isFinite(gradientLocalZMin) || !isFinite(gradientLocalZMax) || gradientLocalZMax <= gradientLocalZMin) {", - " // For ignoreUV, try using world height (Z dimension) as fallback", - " if (ignoreUV) {", - " var fallbackHeight = (autoBoundsMin && autoBoundsMax) ? (autoBoundsMax.z - autoBoundsMin.z) : NaN;", - " if (isFinite(fallbackHeight) && fallbackHeight > 0) {", - " // Assume origin is at center", - " gradientLocalZMin = -fallbackHeight * 0.5;", - " gradientLocalZMax = fallbackHeight * 0.5;", - " } else {", - " gradientLocalZMin = -gradientHeight * 0.5;", - " gradientLocalZMax = gradientHeight * 0.5;", - " }", - " } else {", - " gradientLocalZMin = -gradientHeight * 0.5;", - " gradientLocalZMax = gradientHeight * 0.5;", - " }", + "", + " hasGeometry = gradientSources.length > 0;", + " for (var gradientSource of gradientSources) {", + " gradientBounds.localMin = Math.min(gradientBounds.localMin, gradientSource.bounds.min.z);", + " gradientBounds.localMax = Math.max(gradientBounds.localMax, gradientSource.bounds.max.z);", + " }", + " threeObj.updateWorldMatrix(true, true);", + " foliageExt.refreshGradientBounds(gradientSources, gradientBounds, 1 / getFoliageWorldScale());", + " if (gpuInstancing && gradientSources.length === 1) {", + " // Instanced shaders use their per-instance attribute and mesh-relative fallback.", + " var gradientOriginZ = gradientSources[0].mesh.matrixWorld.elements[14];", + " gradientBounds.worldMin -= gradientOriginZ;", + " gradientBounds.worldMax -= gradientOriginZ;", " }", + "", + " if (!hasGeometry || !isFinite(gradientBounds.localMin) || !isFinite(gradientBounds.localMax) || gradientBounds.localMax <= gradientBounds.localMin) {", + " gradientBounds.localMin = -gradientHeight * 0.5;", + " gradientBounds.localMax = gradientHeight * 0.5;", + " }", + " if (!hasGeometry || !isFinite(gradientBounds.worldMin) || !isFinite(gradientBounds.worldMax) || gradientBounds.worldMax <= gradientBounds.worldMin) {", + " var fallbackHeight = (sizeWorldMin && sizeWorldMax) ? sizeWorldMax.z - sizeWorldMin.z : NaN;", + " if (!isFinite(fallbackHeight) || fallbackHeight <= 0) fallbackHeight = gradientHeight / getFoliageWorldScale();", + " gradientBounds.worldMin = -fallbackHeight * 0.5;", + " gradientBounds.worldMax = fallbackHeight * 0.5;", + " }", + " var gradientLocalZMin = ignoreUV ? gradientBounds.worldMin : gradientBounds.localMin;", + " var gradientLocalZMax = ignoreUV ? gradientBounds.worldMax : gradientBounds.localMax;", " ", " // Automatic sway response estimates bend response from bounds + mesh complexity.", " // It exists so very different assets can share one behavior preset while still", @@ -6665,6 +7094,13 @@ " var sizeX = (autoBoundsMin && autoBoundsMax) ? (autoBoundsMax.x - autoBoundsMin.x) : NaN;", " var sizeY = (autoBoundsMin && autoBoundsMax) ? (autoBoundsMax.y - autoBoundsMin.y) : NaN;", " var sizeZ = (autoBoundsMin && autoBoundsMax) ? (autoBoundsMax.z - autoBoundsMin.z) : NaN;", + " if (!useLocalBoundsForAuto) {", + " // Bounds from matrixWorld are in renderer units; the heuristic uses scene units.", + " var worldScaleForAuto = getFoliageWorldScale();", + " sizeX *= worldScaleForAuto;", + " sizeY *= worldScaleForAuto;", + " sizeZ *= worldScaleForAuto;", + " }", " var maxSize = Math.max(sizeX, sizeY, sizeZ);", " if (!isFinite(maxSize) || maxSize <= 0) maxSize = 1.0;", " ", @@ -6826,6 +7262,8 @@ " #endif", " ", " vec3 worldPos = (m * vec4(transformed, 1.0)).xyz;", + " // Wind uses scene distances while the gradient stays in renderer units.", + " vec3 swayWorldPos = worldPos * uFoliageWorldScale;", " // Use the XY wind-basis uniforms so the common sway path avoids inverse(m).", " ", " // UV-based gradient path.", @@ -6842,10 +7280,22 @@ " // Relative height: how far is this vertex above/below the instance origin in world Z", " float relativeWorldZ = worldPos.z - instanceOrigin.z;", " ", - " // For UV mode, use local Z (mesh local space)", - " // uGradientLocalZMin/Max contain: for world-gradient mode - relative Z range, for UV mode - local Z range.", - " float h = (uUseWorldGradient > 0.5) ? relativeWorldZ : position.z;", - " float localN = (h - uGradientLocalZMin) / max(uGradientLocalZMax - uGradientLocalZMin, 0.0001);", + " // Plain meshes share absolute canopy bounds, even with different local origins.", + " // Instances use mesh-relative bounds; UV mode keeps its existing local Z path.", + " #ifdef USE_INSTANCING", + " float gradientWorldZ = relativeWorldZ;", + " #else", + " float gradientWorldZ = worldPos.z;", + " #endif", + " float h = (uUseWorldGradient > 0.5) ? gradientWorldZ : position.z;", + " vec2 gradientRange = vec2(uGradientLocalZMin, uGradientLocalZMax);", + " #ifdef USE_INSTANCING", + " // Per-instance bounds are stored in scene units; precompile meshes can omit them.", + " if (uUseWorldGradient > 0.5 && aFoliageGradientBounds.y > aFoliageGradientBounds.x) {", + " gradientRange = aFoliageGradientBounds / uFoliageWorldScale;", + " }", + " #endif", + " float localN = (h - gradientRange.x) / max(gradientRange.y - gradientRange.x, 0.0001);", " localN = clamp(localN, 0.0, 1.0);", " float gRawW = (uUseWorldGradient > 0.5) ? localN : (1.0 - localN);", " ", @@ -6861,7 +7311,7 @@ " // This ensures sway follows the material/UV mapping, not world-space position", " float tip = pow(gRawUV, 3.0);", " ", - " float phase = worldPos.x * 0.18 + worldPos.y * 0.18;", + " float phase = swayWorldPos.x * 0.18 + swayWorldPos.y * 0.18;", " `;", " ", " var nodePhaseBlock = `", @@ -6869,7 +7319,7 @@ " `;", " if (!cfg.uniformSway) {", " nodePhaseBlock = `", - " vec3 t = vec3(m[3].x, m[3].y, m[3].z);", + " vec3 t = m[3].xyz * uFoliageWorldScale;", " float nodeSeed = fract(sin(dot(t, vec3(12.9898, 78.233, 37.719))) * 43758.5453);", " float nodePhase = nodeSeed * 6.2831853;", " `;", @@ -6893,7 +7343,7 @@ " float gustAmp = 0.0;", " ", " if (uWindGustEnabled > 0.5 && uWindGustStrength > 0.0) {", - " vec2 p = worldPos.xy;", + " vec2 p = swayWorldPos.xy;", " ", " // Gust wave travels along wind direction", " // gustSamplePos = distance along wind direction (U coordinate)", @@ -6966,7 +7416,7 @@ " // Transform only the extra gust push from world wind into instance space.", " #ifdef USE_GUST", " // Reuse the base wind inverse so gust direction matches the same object basis.", - " vec3 worldGustMove = vec3(dir.x * gustPush, dir.y * gustPush, 0.0);", + " vec3 worldGustMove = vec3(dir.x * gustPush, dir.y * gustPush, 0.0) / uFoliageWorldScale;", " vec3 localGustMove = (baseInvM * vec4(worldGustMove, 0.0)).xyz;", " transformed += localGustMove;", " #endif", @@ -6991,7 +7441,7 @@ " var leavesFlutter = `", " if (uLeavesFlutterStrength > 0.0) {", " float leafMask = pow(vGrad, 2.0);", - " float fPhase = (worldPos.x + worldPos.y) * 0.35;", + " float fPhase = (swayWorldPos.x + swayWorldPos.y) * 0.35;", " // Flutter speed is now tied to wind speed (flutter is faster than base sway)", " float flutterSpeed = uWindSpeed * 4.0;", " float f1 = sin(uFoliageTime * flutterSpeed + fPhase + uSwayPhase * 1.7 + nodePhase);", @@ -7033,7 +7483,7 @@ " ", " // Use only world-space phase for tree trunk (no uSwayPhase/nodePhase variation)", " // This makes all meshes of the same tree move together as one unit", - " float trunkPhase = worldPos.x * 0.18 + worldPos.y * 0.18;", + " float trunkPhase = swayWorldPos.x * 0.18 + swayWorldPos.y * 0.18;", " ", " float t1 = sin(uFoliageTime * (uWindSpeed * 0.40) + trunkPhase * 0.30);", " float t2 = sin(uFoliageTime * (uWindSpeed * 0.60) + trunkPhase * 0.45) * 0.30;", @@ -7044,7 +7494,7 @@ " float twist = sin(uFoliageTime * (uWindSpeed * 0.50) + trunkPhase * 0.25) * (uWindStrength * 0.18) * h2 * uSwayResponse * gustMul;", " // Add gust push for stronger gusts (smaller than grass - trunk is stiffer)", " // Gust push is defined in world wind direction, then transformed to instance-local space.", - " vec3 worldGustTrunkPush = vec3(dir.x * gustPush * 0.3, dir.y * gustPush * 0.3, 0.0);", + " vec3 worldGustTrunkPush = vec3(dir.x * gustPush * 0.3, dir.y * gustPush * 0.3, 0.0) / uFoliageWorldScale;", " mat4 invM = inverse(m);", " vec3 localGustTrunkPush = (invM * vec4(worldGustTrunkPush, 0.0)).xyz;", " // Apply the XY wind basis directly in vertex space.", @@ -7178,21 +7628,35 @@ " #endif", " ", " vec3 worldPos = (m * vec4(transformed, 1.0)).xyz;", + " // Wind uses scene distances while the gradient stays in renderer units.", + " vec3 swayWorldPos = worldPos * uFoliageWorldScale;", " ", " float uvYClamped = clamp(uv.y, 0.0, 1.0);", " float gRawUV = clamp(1.0 - uvYClamped, 0.0, 1.0);", " ", " vec3 instanceOrigin = m[3].xyz;", " float relativeWorldZ = worldPos.z - instanceOrigin.z;", - " float h = (uUseWorldGradient > 0.5) ? relativeWorldZ : position.z;", - " float localN = (h - uGradientLocalZMin) / max(uGradientLocalZMax - uGradientLocalZMin, 0.0001);", + " #ifdef USE_INSTANCING", + " float gradientWorldZ = relativeWorldZ;", + " #else", + " float gradientWorldZ = worldPos.z;", + " #endif", + " float h = (uUseWorldGradient > 0.5) ? gradientWorldZ : position.z;", + " vec2 gradientRange = vec2(uGradientLocalZMin, uGradientLocalZMax);", + " #ifdef USE_INSTANCING", + " // Per-instance bounds are stored in scene units; precompile meshes can omit them.", + " if (uUseWorldGradient > 0.5 && aFoliageGradientBounds.y > aFoliageGradientBounds.x) {", + " gradientRange = aFoliageGradientBounds / uFoliageWorldScale;", + " }", + " #endif", + " float localN = (h - gradientRange.x) / max(gradientRange.y - gradientRange.x, 0.0001);", " localN = clamp(localN, 0.0, 1.0);", " float gRawW = (uUseWorldGradient > 0.5) ? localN : (1.0 - localN);", " float gRaw = (uUseWorldGradient > 0.5) ? gRawW : gRawUV;", " float swayGrad = smoothstep(uGradientStart, uGradientEnd, gRaw);", " float tip = pow(gRawUV, 3.0);", " ", - " float phase = worldPos.x * 0.18 + worldPos.y * 0.18;", + " float phase = swayWorldPos.x * 0.18 + swayWorldPos.y * 0.18;", " `;", " ", " var nodePhaseBlock = `", @@ -7200,7 +7664,7 @@ " `;", " if (!cfg.uniformSway) {", " nodePhaseBlock = `", - " vec3 t = vec3(m[3].x, m[3].y, m[3].z);", + " vec3 t = m[3].xyz * uFoliageWorldScale;", " float nodeSeed = fract(sin(dot(t, vec3(12.9898, 78.233, 37.719))) * 43758.5453);", " float nodePhase = nodeSeed * 6.2831853;", " `;", @@ -7221,7 +7685,7 @@ " float gustAmp = 0.0;", " ", " if (uWindGustEnabled > 0.5 && uWindGustStrength > 0.0) {", - " vec2 p = worldPos.xy;", + " vec2 p = swayWorldPos.xy;", " float gustSamplePos = dot(p, dir);", " float wavePhase = gustSamplePos * uWindGustScale - uFoliageTime * uWindGustSpeed;", " float gustPerpPos = dot(p, perp);", @@ -7272,7 +7736,7 @@ " transformed += localMove;", " ", " #ifdef USE_GUST", - " vec3 worldGustMove = vec3(dir.x * gustPush, dir.y * gustPush, 0.0);", + " vec3 worldGustMove = vec3(dir.x * gustPush, dir.y * gustPush, 0.0) / uFoliageWorldScale;", " vec3 localGustMove = (baseInvM * vec4(worldGustMove, 0.0)).xyz;", " transformed += localGustMove;", " #endif", @@ -7290,7 +7754,7 @@ " var leavesFlutter = `", " if (uLeavesFlutterStrength > 0.0) {", " float leafMask = pow(swayGrad, 2.0);", - " float fPhase = (worldPos.x + worldPos.y) * 0.35;", + " float fPhase = (swayWorldPos.x + swayWorldPos.y) * 0.35;", " float flutterSpeed = uWindSpeed * 4.0;", " float f1 = sin(uFoliageTime * flutterSpeed + fPhase + uSwayPhase * 1.7 + nodePhase);", " float f2 = sin(uFoliageTime * (flutterSpeed * 1.9) + fPhase * 2.3 + uSwayPhase * 0.9 + nodePhase);", @@ -7322,13 +7786,13 @@ " ", " var trunkBlock = `", " float h2 = tip;", - " float trunkPhase = worldPos.x * 0.18 + worldPos.y * 0.18;", + " float trunkPhase = swayWorldPos.x * 0.18 + swayWorldPos.y * 0.18;", " float t1 = sin(uFoliageTime * (uWindSpeed * 0.40) + trunkPhase * 0.30);", " float t2 = sin(uFoliageTime * (uWindSpeed * 0.60) + trunkPhase * 0.45) * 0.30;", " #ifdef USE_GUST", " float bend = (t1 + t2) * (uWindStrength * 0.40) * h2 * uSwayResponse * gustMul;", " float twist = sin(uFoliageTime * (uWindSpeed * 0.50) + trunkPhase * 0.25) * (uWindStrength * 0.18) * h2 * uSwayResponse * gustMul;", - " vec3 worldGustTrunkPush = vec3(dir.x * gustPush * 0.3, dir.y * gustPush * 0.3, 0.0);", + " vec3 worldGustTrunkPush = vec3(dir.x * gustPush * 0.3, dir.y * gustPush * 0.3, 0.0) / uFoliageWorldScale;", " mat4 invM = inverse(m);", " vec3 localGustTrunkPush = (invM * vec4(worldGustTrunkPush, 0.0)).xyz;", " vec2 localTrunkMove = localDir * bend + localPerp * (bend * 0.25 + twist) + vec2(localGustTrunkPush.x, localGustTrunkPush.y);", @@ -7364,6 +7828,7 @@ " if (!shadowMat) return;", " shadowMat.userData = shadowMat.userData || {};", " shadowMat.userData.foliageConfig = cfgSource;", + " cache.syncMaterialGustDefine(shadowMat);", " if (shadowMat.userData.__foliageShadowPatched) return;", " ", " shadowMat.userData.__foliageShadowPatched = true;", @@ -7378,19 +7843,21 @@ " return;", " }", " var useShadowFade = !!cfgShadow.distanceFadeEnabled;", - " if (!shader.defines) shader.defines = {};", - " if (cache.gustEnabled) shader.defines.USE_GUST = \"\";", - " else delete shader.defines.USE_GUST;", "", " var pendingShadowFade = (this.userData && typeof this.userData._pendingFade === \"number\")", " ? this.userData._pendingFade", " : 1.0;", - " shader.uniforms.uFoliageTime = { value: 0.0 };", - " shader.uniforms.uWindStrength = { value: 0.5 };", - " shader.uniforms.uWindSpeed = { value: 1.0 };", - " shader.uniforms.uWindDirection = { value: new THREE.Vector2(1, 0) };", - " shader.uniforms.uLocalWindDirection = { value: new THREE.Vector2(1, 0) };", - " shader.uniforms.uLocalWindPerpendicular = { value: new THREE.Vector2(0, 1) };", + " // A recompiled variant must start with the current frame, not reset wind values.", + " var windFrame = cache._frameCtx;", + " var windX = windFrame ? windFrame.wx : 1;", + " var windY = windFrame ? windFrame.wy : 0;", + " shader.uniforms.uFoliageTime = { value: windFrame ? windFrame.time : 0.0 };", + " shader.uniforms.uFoliageWorldScale = { value: getFoliageWorldScale() };", + " shader.uniforms.uWindStrength = { value: windFrame ? windFrame.windStrength : 0.5 };", + " shader.uniforms.uWindSpeed = { value: windFrame ? windFrame.windSpeed : 1.0 };", + " shader.uniforms.uWindDirection = { value: new THREE.Vector2(windX, windY) };", + " shader.uniforms.uLocalWindDirection = { value: new THREE.Vector2(windX, windY) };", + " shader.uniforms.uLocalWindPerpendicular = { value: new THREE.Vector2(-windY, windX) };", " shader.uniforms.uSwayPhase = { value: cfgShadow.phase || 0.0 };", " shader.uniforms.uSwayResponse = { value: cfgShadow.swayResponse || 1.0 };", " shader.uniforms.uLeavesFlutterStrength = { value: cfgShadow.swayType === \"Leaves\" && cfgShadow.swayResponse >= 0.6 ? 0.4 * cfgShadow.swayResponse : 0.0 };", @@ -7408,17 +7875,18 @@ " shader.uniforms.uFadeInterpolation = { value: 1.0 };", " }", "", - " if (cache.gustEnabled) {", - " shader.uniforms.uWindGustTexture = { value: cache.gustTexture || cache.gustFallbackTex };", - " shader.uniforms.uWindGustEnabled = { value: 1.0 };", - " shader.uniforms.uWindGustStrength = { value: cache.gustStrength };", - " shader.uniforms.uWindGustScale = { value: cache.gustScale };", - " shader.uniforms.uWindGustSpeed = { value: cache.gustSpeed };", - " shader.uniforms.uWindGustThreshold = { value: cache.gustThreshold };", - " shader.uniforms.uWindGustContrast = { value: cache.gustContrast };", - " }", + " // Cached plain/instanced programs can be reused without onBeforeCompile.", + " // Keep gust uniforms available even when USE_GUST strips the shader branch.", + " shader.uniforms.uWindGustTexture = { value: cache.gustTexture || cache.gustFallbackTex };", + " shader.uniforms.uWindGustEnabled = { value: cache.gustEnabled ? 1.0 : 0.0 };", + " shader.uniforms.uWindGustStrength = { value: cache.gustEnabled ? cache.gustStrength : 0.0 };", + " shader.uniforms.uWindGustScale = { value: cache.gustScale };", + " shader.uniforms.uWindGustSpeed = { value: cache.gustSpeed };", + " shader.uniforms.uWindGustThreshold = { value: cache.gustThreshold };", + " shader.uniforms.uWindGustContrast = { value: cache.gustContrast };", " ", " this.userData.foliageUniforms = shader.uniforms;", + " delete this.userData.__gustVersionApplied;", " ", " var shadowFadeVertexDecl = useShadowFade ? `", " #ifdef USE_INSTANCING", @@ -7431,7 +7899,11 @@ " ` : ``;", " var commonShadowVertex = `", " #include ", + " #ifdef USE_INSTANCING", + " attribute vec2 aFoliageGradientBounds;", + " #endif", " uniform float uFoliageTime;", + " uniform float uFoliageWorldScale;", " uniform float uWindStrength;", " uniform float uWindSpeed;", " uniform vec2 uWindDirection;", @@ -7527,6 +7999,8 @@ " function computeInitialNonInstancedFade(gdObject, threeObject, startDistance, endDistance) {", " if (!runtimeScene || !distanceFadeEnabled) return 1.0;", "", + " var inverseWorldScaleInit = typeof runtimeScene.getRenderer3DInverseWorldScale === \"function\"", + " ? runtimeScene.getRenderer3DInverseWorldScale() : 1;", " var camXInit = 0;", " var camYInit = 0;", " var hasCamera = false;", @@ -7559,6 +8033,8 @@ " try {", " objXInit = gdObject.getX ? gdObject.getX() : 0;", " objYInit = gdObject.getY ? gdObject.getY() : 0;", + " objXInit *= inverseWorldScaleInit;", + " objYInit *= -inverseWorldScaleInit;", " gotPos = true;", " } catch (eInitPos2D) {}", " }", @@ -7567,19 +8043,21 @@ " var safeStart = isFinite(startDistance) ? startDistance : 1200;", " var safeEnd = isFinite(endDistance) ? endDistance : 1600;", " if (safeEnd <= safeStart) safeEnd = safeStart + 100;", + " safeStart *= inverseWorldScaleInit;", + " safeEnd *= inverseWorldScaleInit;", "", " var dxInit = objXInit - camXInit;", " var dyInit = objYInit - camYInit;", " var distSqInit = dxInit * dxInit + dyInit * dyInit;", " var startSqInit = safeStart * safeStart;", - " var marginInit = 256.0;", + " var marginInit = 256.0 * inverseWorldScaleInit;", " var endWithMarginInit = safeEnd + marginInit;", " var endWithMarginSqInit = endWithMarginInit * endWithMarginInit;", " if (distSqInit > endWithMarginSqInit) return 0.0;", " if (distSqInit < startSqInit) return 1.0;", "", " var rangeInit = safeEnd - safeStart;", - " if (rangeInit <= 0) rangeInit = 100;", + " if (rangeInit <= 0) rangeInit = 100 * inverseWorldScaleInit;", " var distInit = Math.sqrt(distSqInit);", " var tInit = (distInit - safeStart) / rangeInit;", " if (tInit < 0) tInit = 0;", @@ -7788,6 +8266,7 @@ " * @returns {void}", " */", " function patchMaterialIfNeeded(mat) {", + " cache.syncMaterialGustDefine(mat);", " if (cache.patchedMaterials.has(mat)) return;", " cache.patchedMaterials.add(mat);", " ", @@ -7811,24 +8290,19 @@ " var cfg2 = this.userData.foliageConfig;", " var gustTex = cache.gustTexture || cache.gustFallbackTex;", " ", - " // Some materials may not set defines before onBeforeCompile", - " if (!shader.defines) shader.defines = {};", - " ", - " // Compile-time gust branch strips gust code when disabled.", - " if (cache.gustEnabled) {", - " shader.defines.USE_GUST = \"\";", - " } else {", - " delete shader.defines.USE_GUST;", - " }", - " ", - " shader.uniforms.uFoliageTime = { value: 0.0 };", - " shader.uniforms.uWindStrength = { value: 0.5 };", - " shader.uniforms.uWindSpeed = { value: 1.0 };", - " shader.uniforms.uWindDirection = { value: new THREE.Vector2(1, 0) };", + " // A recompiled variant must start with the current frame, not reset wind values.", + " var windFrame = cache._frameCtx;", + " var windX = windFrame ? windFrame.wx : 1;", + " var windY = windFrame ? windFrame.wy : 0;", + " shader.uniforms.uFoliageTime = { value: windFrame ? windFrame.time : 0.0 };", + " shader.uniforms.uFoliageWorldScale = { value: getFoliageWorldScale() };", + " shader.uniforms.uWindStrength = { value: windFrame ? windFrame.windStrength : 0.5 };", + " shader.uniforms.uWindSpeed = { value: windFrame ? windFrame.windSpeed : 1.0 };", + " shader.uniforms.uWindDirection = { value: new THREE.Vector2(windX, windY) };", " // Keep a secondary XY wind basis uniform so the common sway path avoids", " // per-vertex inverse(m). It currently mirrors the world XY wind direction.", - " var localWindDir = new THREE.Vector2(1, 0);", - " var localWindPerp = new THREE.Vector2(0, 1);", + " var localWindDir = new THREE.Vector2(windX, windY);", + " var localWindPerp = new THREE.Vector2(-windY, windX);", " shader.uniforms.uLocalWindDirection = { value: localWindDir };", " shader.uniforms.uLocalWindPerpendicular = { value: localWindPerp };", " shader.uniforms.uSwayPhase = { value: cfg2.phase };", @@ -7863,25 +8337,29 @@ " var flutterAllowed = cfg2.swayType === \"Leaves\" && cfg2.swayResponse >= 0.6;", " shader.uniforms.uLeavesFlutterStrength = { value: flutterAllowed ? 0.4 * cfg2.swayResponse : 0.0 };", " ", - " // Set gust uniforms only when gust is enabled.", - " if (cache.gustEnabled) {", - " shader.uniforms.uWindGustTexture = { value: gustTex };", - " shader.uniforms.uWindGustEnabled = { value: 1.0 };", - " shader.uniforms.uWindGustStrength = { value: cache.gustStrength };", - " shader.uniforms.uWindGustScale = { value: cache.gustScale };", - " shader.uniforms.uWindGustSpeed = { value: cache.gustSpeed };", - " shader.uniforms.uWindGustThreshold = { value: cache.gustThreshold };", - " shader.uniforms.uWindGustContrast = { value: cache.gustContrast };", - " }", + " // Cached plain/instanced programs can be reused without onBeforeCompile.", + " // Keep gust uniforms available even when USE_GUST strips the shader branch.", + " shader.uniforms.uWindGustTexture = { value: gustTex };", + " shader.uniforms.uWindGustEnabled = { value: cache.gustEnabled ? 1.0 : 0.0 };", + " shader.uniforms.uWindGustStrength = { value: cache.gustEnabled ? cache.gustStrength : 0.0 };", + " shader.uniforms.uWindGustScale = { value: cache.gustScale };", + " shader.uniforms.uWindGustSpeed = { value: cache.gustSpeed };", + " shader.uniforms.uWindGustThreshold = { value: cache.gustThreshold };", + " shader.uniforms.uWindGustContrast = { value: cache.gustContrast };", " ", " // GPU fade smoothing: interpolation factor updated every frame", " shader.uniforms.uFadeInterpolation = { value: 1.0 };", " ", " this.userData.foliageUniforms = shader.uniforms;", + " delete this.userData._lastUniforms;", + " delete this.userData.__gustVersionApplied;", + " // Reclassify variants compiled after gusts are enabled or disabled.", + " cache.registerActiveMaterial(this);", " ", " var commonVertex = `", " #include ", " uniform float uFoliageTime;", + " uniform float uFoliageWorldScale;", " uniform float uWindStrength;", " uniform float uWindSpeed;", " uniform vec2 uWindDirection;", @@ -7918,6 +8396,7 @@ " #ifdef USE_INSTANCING", " attribute float aFade;", " attribute float aFadePrev;", + " attribute vec2 aFoliageGradientBounds;", " #endif", " uniform float uFadeInterpolation;", " varying float vFade;", @@ -8066,6 +8545,7 @@ " // World-gradient config.", " ignoreUV: !!ignoreUV,", " gradientHeight: gradientHeight,", + " gradientBounds: gradientBounds,", " gradientLocalZMin: gradientLocalZMin,", " gradientLocalZMax: gradientLocalZMax,", " receiveShadow: false", @@ -8117,6 +8597,7 @@ " // Keep world-gradient config in sync for shared material.", " cfg3.ignoreUV = !!ignoreUV;", " cfg3.gradientHeight = gradientHeight;", + " cfg3.gradientBounds = gradientBounds;", " cfg3.gradientLocalZMin = gradientLocalZMin;", " cfg3.gradientLocalZMax = gradientLocalZMax;", " applyCustomPBRIfSupported(entry.material, cfg3.customPBREnabled, cfg3.metallic, cfg3.roughness, cfg3.specular, cfg3.normalStrength, cfg3.ambientOcclusionStrength, cfg3.environmentStrength);", @@ -8286,6 +8767,8 @@ "\t }", " }", " if (useTwoPartTree) {", + " var trunkGeometry = objectTypeCache.getRenderGeometry(repMeshTrunk, selectionTrunk);", + " var leavesGeometry = objectTypeCache.getRenderGeometry(repMeshLeaves, selection);", " // Global instancing: geoKey stays a stable asset+mesh identity without runtime-instance suffixes.", " var trunkPickedId = selectionTrunk && selectionTrunk.pickedId ? String(selectionTrunk.pickedId) : \"\";", " var leavesPickedId = selection && selection.pickedId ? String(selection.pickedId) : \"\";", @@ -8301,8 +8784,10 @@ "\t /** @type {FoliageObjectTypeCacheEntryBridge} */", "\t var splitFastCacheEntry = objectTypeCache.get(fastCacheKeySplit) || {};", "\t splitFastCacheEntry._gpuFastMode = \"splitLeavesTree\";", - "\t splitFastCacheEntry._gpuTrunkGeometry = repMeshTrunk.geometry;", - "\t splitFastCacheEntry._gpuLeavesGeometry = repMeshLeaves.geometry;", + "\t splitFastCacheEntry._gpuTrunkGeometry = trunkGeometry;", + " splitFastCacheEntry._gpuTrunkSourceGeometry = repMeshTrunk.geometry;", + "\t splitFastCacheEntry._gpuLeavesGeometry = leavesGeometry;", + " splitFastCacheEntry._gpuLeavesSourceGeometry = repMeshLeaves.geometry;", "\t splitFastCacheEntry._gpuTrunkBaseGroupKey = baseGroupKeyTrunk;", "\t splitFastCacheEntry._gpuLeavesBaseGroupKey = baseGroupKeyLeaves;", "\t splitFastCacheEntry._gpuTrunkSharedMaterialKey = keyTrunk;", @@ -8322,8 +8807,8 @@ " behaviorName: \"FoliageSwaying\",", " queueId: queueIdTree,", " parts: [", - " { repMesh: repMeshTrunk, geometry: repMeshTrunk.geometry, material: sharedMatTrunk, baseGroupKey: baseGroupKeyTrunk },", - " { repMesh: repMeshLeaves, geometry: repMeshLeaves.geometry, material: sharedMat, baseGroupKey: baseGroupKeyLeaves }", + " { repMesh: repMeshTrunk, geometry: trunkGeometry, material: sharedMatTrunk, baseGroupKey: baseGroupKeyTrunk },", + " { repMesh: repMeshLeaves, geometry: leavesGeometry, material: sharedMat, baseGroupKey: baseGroupKeyLeaves }", " ]", " });", " behavior.__foliageQueued = true;", @@ -8374,7 +8859,7 @@ " // Fallback to non-instanced behavior if we cannot resolve geometry", " gpuInstancing = false;", " } else {", - " if (!geo) geo = repMesh.geometry;", + " geo = objectTypeCache.getRenderGeometry(repMesh, selection);", " {", " // Global instancing: geoKey stays a stable asset+mesh identity without runtime-instance suffixes.", " var materialPickedId = selection && selection.pickedId ? String(selection.pickedId) : \"\";", @@ -8401,6 +8886,7 @@ "\t _gpuFastMode: \"single\",", "\t _gpuGroupKey: geoGroupKey,", "\t _gpuGeometry: geo,", + " _gpuSourceGeometry: repMesh.geometry,", "\t _sharedMaterialKey: key,", " _resolvedSide: resolvedRenderSide", " });", @@ -8448,15 +8934,21 @@ " var trunkMatFound = null;", " /** @type {FoliageMeshLike[]} */", " var trunkMeshes = [];", - " if (useNonInstancedFade) {", + " // Plain objects need independent gradient configs even when distance fade is off.", + " {", " var fadeMat = sharedMat.clone();", - " fadeMat.userData = { foliageConfig: sharedMat.userData.foliageConfig };", + " fadeMat.userData = { foliageConfig: Object.assign({}, sharedMat.userData.foliageConfig, {", + " gradientBounds: gradientBounds,", + " topColor: topColor.clone(),", + " bottomColor: bottomColor.clone()", + " }) };", " if (typeof sharedMat.side === \"number\") fadeMat.side = sharedMat.side;", " applyPendingFadeToMaterial(fadeMat, initialNonInstancedFade);", " // Explicitly chain sharedMat's onBeforeCompile (full patch) then add uDistanceFade; clone() may not copy onBeforeCompile.", " var sharedOnBeforeCompile = sharedMat.onBeforeCompile;", " fadeMat.onBeforeCompile = function (shader) {", " if (sharedOnBeforeCompile) sharedOnBeforeCompile.call(fadeMat, shader);", + " if (!fadeMat.userData.foliageConfig.distanceFadeEnabled) return;", " var pendingFade = (fadeMat.userData && typeof fadeMat.userData._pendingFade === \"number\") ? fadeMat.userData._pendingFade : 1.0;", " shader.uniforms.uDistanceFade = { value: pendingFade };", " var hasUFadeUniform = /uniform\\s+float\\s+uDistanceFade\\s*;/.test(shader.vertexShader);", @@ -8545,7 +9037,7 @@ " ", " mesh.material = newMats2;", " });", - " if (useNonInstancedFade) {", + " {", " // Leaves two-part: trunk must fade with leaves (same uDistanceFade).", " if (useSplitLeavesFade && trunkMatFound) {", " /** @type {THREE.Material} */", @@ -8600,6 +9092,7 @@ " fadeEnd: fadeEnd,", " fadeEnabled: !!distanceFadeEnabled,", " fadeBehavior: behavior || null,", + " gradientSources: gradientSources,", " _parkedNoFade: false,", " _firstHideWarmupDone: false", " });",