diff --git a/extensions/community/FoliageSwaying3D.json b/extensions/community/FoliageSwaying3D.json index 24b1cb789..56c5feeb1 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.2.0", + "version": "1.3.1", "description": [ "Sway foliage of 3D objects according to wind.", "", @@ -118,6 +118,10 @@ " */", "", "", + " // Chunk membership uses GDevelop XY scene units, independently of renderer scale.", + " const FOLIAGE_CHUNK_SIZE = 512;", + " const FOLIAGE_MIN_INSTANCE_CAPACITY = 16;", + "", " /** @typedef {{ material: THREE.Material, refCount: number, _ownedByFoliage?: boolean }} FoliageSharedMaterialEntry */", " /** @typedef {THREE.Material & { alphaMap?: THREE.Texture|null, map?: THREE.Texture|null, alphaTest?: number, transparent?: boolean, opacity?: number, name?: string }} FoliageInspectableMaterial */", " /** @typedef {{ strength: number, scale: number, speed: number, threshold: number, contrast: number }} GustDefaults */", @@ -127,7 +131,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, 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 {{ 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 }} 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.", @@ -139,6 +143,11 @@ " * @param {FoliageBehavior} behavior", " */", " constructor(behavior) {", + " this.readFrom(behavior);", + " }", + "", + " /** @param {FoliageBehavior} behavior @returns {void} */", + " readFrom(behavior) {", " var materialName = behavior._getMaterialName();", " var swayType = behavior._getSwayType();", " var cullingMode = behavior._getCullingMode();", @@ -178,6 +187,35 @@ " }", "", " /**", + " * Owns two reusable property snapshots for hot-reload comparisons, not live reads.", + " * Every poll rereads getters; snapshots are reused after the first two polls.", + " */", + " class FoliageHotReloadState {", + " /** @param {FoliageBehavior} behavior */", + " constructor(behavior) {", + " this.props = new FoliageBehaviorProps(behavior);", + " /** @type {FoliageBehaviorProps|null} */", + " this._previous = null;", + " this.runtimeChanged = true;", + " this.structuralChanged = false;", + " }", + "", + " /** @param {FoliageBehavior} behavior @returns {void} */", + " refresh(behavior) {", + " var previous = this.props;", + " if (this._previous) {", + " this.props = this._previous;", + " this.props.readFrom(behavior);", + " } else {", + " this.props = new FoliageBehaviorProps(behavior);", + " }", + " this._previous = previous;", + " this.runtimeChanged = !sameRuntimeHotReloadProps(previous, this.props);", + " this.structuralChanged = !sameStructuralHotReloadProps(previous, this.props);", + " }", + " }", + "", + " /**", " * Per-frame material categorization used by update passes to touch only the", " * materials that need a specific uniform or rebuild step.", " */", @@ -293,7 +331,7 @@ " });", " }", " }", - " /** @typedef {{ __foliageSkipOnDestroy?: boolean, __foliageNonInstancedRegistered?: boolean, __foliageSharedKey?: string, __foliageSharedKeyTrunk?: string, __foliageInstancingGroupKey?: string, __foliageInstancingGroupKeyLeaves?: string, __foliageInstancingIndex?: number, __foliageQueued?: boolean, __foliageQueueId?: number, __foliageInstancingRepresentative?: boolean, __foliageRepresentativeTypeKey?: string, __foliageHotReloadRuntimeSig?: string, __foliageHotReloadStructuralSig?: string, __foliageHotReloadStructuralDirty?: boolean, __foliageHotReloadWarnedStructural?: boolean }} FoliageBehaviorPrivateFields */", + " /** @typedef {{ __foliageSkipOnDestroy?: boolean, __foliageNonInstancedRegistered?: boolean, __foliageSharedKey?: string, __foliageSharedKeyTrunk?: string, __foliageInstancingGroupKey?: string, __foliageInstancingGroupKeyLeaves?: string, __foliageInstancingIndex?: number, __foliageQueued?: boolean, __foliageQueueId?: number, __foliageInstancingRepresentative?: boolean, __foliageRepresentativeTypeKey?: string, __foliageHotReloadState?: FoliageHotReloadState, __foliageHotReloadStructuralDirty?: boolean, __foliageHotReloadWarnedStructural?: boolean }} FoliageBehaviorPrivateFields */", " /** @typedef {gdjs.RuntimeBehavior & FoliageBehaviorPrivateFields & FoliageBehaviorGetterMethods} FoliageBehavior */", " /** @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 {{ zMin: number, zMax: number, relZMin: number, relZMax: number, hasGeom: boolean, totalVerts: number, totalTris: number, meshCount: number, slotCount: number, planeLikeCount: number, sizeLocalMin: THREE.Vector3, sizeLocalMax: THREE.Vector3, sizeWorldMin: THREE.Vector3, sizeWorldMax: THREE.Vector3, _meshIds?: Set }} FoliageDataRecord */", @@ -302,7 +340,7 @@ " /** @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, 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 {{ 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, _lastFadeEnabled?: boolean, _packingDirty?: boolean, visibleCount?: number, instanceDetSign?: number, _srcGeometryRef?: THREE.BufferGeometry|null, _ownedGeometry?: THREE.BufferGeometry|null, _geometryLease?: FoliageGeometryLease|null, _distanceBounds?: FoliageDistanceBounds|null, _distanceBoundsDirty?: boolean, _distanceOutsideTicks?: number }} 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, _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 */", " /**", @@ -313,11 +351,38 @@ " * @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, 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 {{ 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, _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 }} FoliageSceneStateShape */", + " /** @typedef {{ uniforms: { [name: string]: { value: unknown } }, fragmentShader: string }} FoliagePbrShader */", + " /** @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, applyFoliageNormalStrength: (mat: THREE.Material, strength: number) => void, patchFoliagePbrShader: (mat: THREE.Material, shader: FoliagePbrShader) => void, setFoliageProgramCacheKey: (mat: THREE.Material, patchKind: string, sourceMat?: THREE.Material) => void, 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 */", " ", + " /**", + " * Copy raw components so normalized integer and half-float attributes retain", + " * their storage format. Interleaved inputs become compact standalone attributes.", + " * @param {THREE.BufferAttribute|THREE.InterleavedBufferAttribute} source", + " * @param {number[]} vertices", + " * @returns {THREE.BufferAttribute}", + " */", + " function copyFoliageSubsetAttribute(source, vertices) {", + " var stride = source instanceof THREE.InterleavedBufferAttribute ? source.data.stride : source.itemSize;", + " var offset = source instanceof THREE.InterleavedBufferAttribute ? source.offset : 0;", + " var ArrayType = /** @type {{ new(length: number): THREE.TypedArray }} */ (source.array.constructor);", + " var data = new ArrayType(vertices.length * source.itemSize);", + " for (var i = 0; i < vertices.length; i++) {", + " for (var j = 0; j < source.itemSize; j++) {", + " data[i * source.itemSize + j] = source.array[vertices[i] * stride + offset + j];", + " }", + " }", + " var result = source instanceof THREE.Float16BufferAttribute", + " ? new THREE.Float16BufferAttribute(data, source.itemSize, source.normalized)", + " : new THREE.BufferAttribute(data, source.itemSize, source.normalized);", + " result.name = source.name;", + " result.setUsage(source instanceof THREE.InterleavedBufferAttribute ? source.data.usage : source.usage);", + " if (source instanceof THREE.BufferAttribute) result.gpuType = source.gpuType;", + " return result;", + " }", + "", " /** @typedef {{ bounds: THREE.Box3, indices: number[], geometry: THREE.BufferGeometry|null }} FoliageMaterialSubset */", " /** @typedef {{ mesh: FoliageMeshLike, bounds: THREE.Box3, zRow: number[] }} FoliageGradientSource */", "", @@ -442,12 +507,36 @@ " 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();", + " var source = mesh.geometry;", + " var geometry = new THREE.BufferGeometry();", + " // Remap by original index, not position: UV seams and hard normals stay distinct.", + " /** @type {Map} */", + " var remap = new Map();", + " /** @type {number[]} */", + " var vertices = [];", + " /** @type {number[]} */", + " var indices = [];", + " for (var vertex of subset.indices) {", + " var mapped = remap.get(vertex);", + " if (mapped === undefined) {", + " mapped = vertices.length;", + " remap.set(vertex, mapped);", + " vertices.push(vertex);", + " }", + " indices.push(mapped);", + " }", + " geometry.setIndex(indices);", + " for (var name of Object.keys(source.attributes)) {", + " geometry.setAttribute(name, copyFoliageSubsetAttribute(source.attributes[name], vertices));", + " }", + " for (var name of Object.keys(source.morphAttributes)) {", + " geometry.morphAttributes[name] = source.morphAttributes[name].map(function(attribute) {", + " return copyFoliageSubsetAttribute(attribute, vertices);", + " });", + " }", + " geometry.morphTargetsRelative = source.morphTargetsRelative;", + " geometry.setDrawRange(0, indices.length);", + " subset.geometry = geometry;", " subset.geometry.computeBoundingBox();", " subset.geometry.computeBoundingSphere();", " this.ownedSubsetGeometries.add(subset.geometry);", @@ -1100,49 +1189,75 @@ " return isFinite(value) ? Math.round(value * 1000) : 0;", " }", "", + " /** @param {number} a @param {number} b @returns {boolean} */", + " function sameHotReloadNumber(a, b) {", + " // Preserve the existing 0.001 rounding without serializing numbers.", + " return a === b || sigNum(a) === sigNum(b);", + " }", + "", + " /** @param {FoliageBehaviorProps} a @param {FoliageBehaviorProps} b @returns {boolean} */", + " function sameRuntimeHotReloadProps(a, b) {", + " return a.distanceFadeEnabled === b.distanceFadeEnabled &&", + " sameHotReloadNumber(a.fadeStart, b.fadeStart) &&", + " sameHotReloadNumber(a.fadeEnd, b.fadeEnd) &&", + " a.useColorGradient === b.useColorGradient &&", + " a.colorTop === b.colorTop && a.colorBottom === b.colorBottom &&", + " sameHotReloadNumber(a.colorContrast, b.colorContrast) &&", + " sameHotReloadNumber(a.colorSaturation, b.colorSaturation) &&", + " sameHotReloadNumber(a.gradientStart, b.gradientStart) &&", + " sameHotReloadNumber(a.gradientEnd, b.gradientEnd) &&", + " a.ignoreUV === b.ignoreUV && a.twoSidedLighting === b.twoSidedLighting &&", + " sameHotReloadNumber(a.swayResponse, b.swayResponse) &&", + " a.metallic === b.metallic &&", + " a.roughness === b.roughness &&", + " a.specular === b.specular &&", + " a.normalStrength === b.normalStrength &&", + " a.ambientOcclusionStrength === b.ambientOcclusionStrength &&", + " a.environmentStrength === b.environmentStrength;", + " }", + "", + " /** @param {FoliageBehaviorProps} a @param {FoliageBehaviorProps} b @returns {boolean} */", + " function sameStructuralHotReloadProps(a, b) {", + " return a.materialName === b.materialName && a.swayType === b.swayType &&", + " a.gpuInstancing === b.gpuInstancing && a.uniformSway === b.uniformSway &&", + " a.cullingMode === b.cullingMode && a.customPBREnabled === b.customPBREnabled;", + " }", + "", " /**", - " * Build the signature for values that can be applied to existing runtime materials.", - " * @param {FoliageBehaviorProps} props", - " * @returns {string}", + " * Preserve the source normal-map axis signs, including across a strength of zero.", + " * Signs live in the material config so plain and mirrored clones inherit them.", + " * @param {THREE.Material} mat", + " * @param {number} strength", " */", - " function buildRuntimeHotReloadSignature(props) {", - " return [", - " props.distanceFadeEnabled ? 1 : 0,", - " sigNum(props.fadeStart),", - " sigNum(props.fadeEnd),", - " props.useColorGradient ? 1 : 0,", - " props.colorTop,", - " props.colorBottom,", - " sigNum(props.colorContrast),", - " sigNum(props.colorSaturation),", - " sigNum(props.gradientStart),", - " sigNum(props.gradientEnd),", - " props.ignoreUV ? 1 : 0,", - " props.twoSidedLighting ? 1 : 0,", - " sigNum(props.swayResponse),", - " sigNum(props.metallic),", - " sigNum(props.roughness),", - " sigNum(props.specular),", - " sigNum(props.normalStrength),", - " sigNum(props.ambientOcclusionStrength),", - " sigNum(props.environmentStrength)", - " ].join(\"|\");", + " function applyFoliageNormalStrength(mat, strength) {", + " var normalMat = /** @type {THREE.Material & { normalScale?: THREE.Vector2 }} */ (mat);", + " if (!normalMat.normalScale) return;", + " var cfg = mat.userData.foliageConfig;", + " if (!cfg) return;", + " if (typeof cfg.normalScaleSignX !== \"number\") cfg.normalScaleSignX = normalMat.normalScale.x < 0 ? -1 : 1;", + " if (typeof cfg.normalScaleSignY !== \"number\") cfg.normalScaleSignY = normalMat.normalScale.y < 0 ? -1 : 1;", + " normalMat.normalScale.set(strength * cfg.normalScaleSignX, strength * cfg.normalScaleSignY);", " }", "", " /**", - " * Build the signature for values that currently require material/group reinitialization.", - " * @param {FoliageBehaviorProps} props", - " * @returns {string}", + " * Override PBR environment intensity without taking ownership of the environment.", + " * Three.js still selects the current material/scene map and its rotation. Using", + " * our own uniform also works when newer renderers substitute scene intensity.", + " * @param {THREE.Material} mat", + " * @param {FoliagePbrShader} shader", " */", - " function buildStructuralHotReloadSignature(props) {", - " return [", - " props.materialName,", - " props.swayType,", - " props.gpuInstancing ? 1 : 0,", - " props.uniformSway ? 1 : 0,", - " props.cullingMode,", - " props.customPBREnabled ? 1 : 0", - " ].join(\"|\");", + " function patchFoliagePbrShader(mat, shader) {", + " var pbrMat = /** @type {THREE.Material & { isMeshStandardMaterial?: boolean }} */ (mat);", + " var cfg = mat.userData.foliageConfig;", + " if (!pbrMat.isMeshStandardMaterial || !cfg || !cfg.customPBREnabled) return;", + " if (shader.uniforms.uFoliageEnvironmentStrength) return;", + " var anchor = \"#include \";", + " if (shader.fragmentShader.indexOf(anchor) === -1) return;", + " shader.uniforms.uFoliageEnvironmentStrength = { value: cfg.environmentStrength };", + " shader.fragmentShader = shader.fragmentShader.replace(anchor,", + " \"uniform float uFoliageEnvironmentStrength;\\n\" +", + " THREE.ShaderChunk.envmap_physical_pars_fragment.replace(/\\benvMapIntensity\\b/g, \"uFoliageEnvironmentStrength\")", + " );", " }", "", " /**", @@ -1159,9 +1274,7 @@ " if (typeof pbrMat.specularIntensity !== \"undefined\") pbrMat.specularIntensity = props.specular;", " if (typeof pbrMat.aoMapIntensity !== \"undefined\") pbrMat.aoMapIntensity = props.ambientOcclusionStrength;", " if (typeof pbrMat.envMapIntensity !== \"undefined\") pbrMat.envMapIntensity = props.environmentStrength;", - " if (pbrMat.normalScale && typeof pbrMat.normalScale.set === \"function\") {", - " pbrMat.normalScale.set(props.normalStrength, props.normalStrength);", - " }", + " applyFoliageNormalStrength(mat, props.normalStrength);", " }", "", " /**", @@ -1196,8 +1309,12 @@ " cfg.distanceFadeEnabled = !!props.distanceFadeEnabled;", " mat.needsUpdate = true;", " fadeChanged = true;", - " if (mat.userData._foliageDepthMat) mat.userData._foliageDepthMat.needsUpdate = true;", - " if (mat.userData._foliageDistanceMat) mat.userData._foliageDistanceMat.needsUpdate = true;", + " // Signed shadows own config copies rather than the host's config object.", + " for (const shadow of [mat.userData._foliageDepthMat, mat.userData._foliageDistanceMat]) {", + " if (!shadow) continue;", + " if (shadow.userData.foliageConfig) shadow.userData.foliageConfig.distanceFadeEnabled = cfg.distanceFadeEnabled;", + " shadow.needsUpdate = true;", + " }", " }", "", " var topColor = parseColor(props.colorTop, \"#f9372b\");", @@ -1252,6 +1369,10 @@ " cfg.ambientOcclusionStrength = props.ambientOcclusionStrength;", " cfg.environmentStrength = props.environmentStrength;", " applyHotReloadPbrIfSupported(mat, props);", + " var pbrUniforms = mat.userData.foliageUniforms;", + " if (pbrUniforms && pbrUniforms.uFoliageEnvironmentStrength) {", + " pbrUniforms.uFoliageEnvironmentStrength.value = props.environmentStrength;", + " }", " }", "", " if (u) {", @@ -1286,28 +1407,21 @@ " function syncObjectHotReload(gdObj, behavior, behaviorName) {", " if (!gdObj || !behavior) return false;", " var state = /** @type {FoliageSceneState} */ (this);", - " var props = new FoliageBehaviorProps(behavior);", - "", - " var runtimeSig = buildRuntimeHotReloadSignature(props);", - " var structuralSig = buildStructuralHotReloadSignature(props);", - " if (behavior.__foliageHotReloadStructuralSig === undefined) {", - " behavior.__foliageHotReloadStructuralSig = structuralSig;", - " } else if (behavior.__foliageHotReloadStructuralSig !== structuralSig) {", - " behavior.__foliageHotReloadStructuralSig = structuralSig;", + " var hotReload = behavior.__foliageHotReloadState;", + " if (hotReload) {", + " hotReload.refresh(behavior);", + " } else {", + " hotReload = behavior.__foliageHotReloadState = new FoliageHotReloadState(behavior);", + " }", + " var props = hotReload.props;", + " if (hotReload.structuralChanged) {", " behavior.__foliageHotReloadStructuralDirty = true;", " if (props.debugOutput && !behavior.__foliageHotReloadWarnedStructural) {", " behavior.__foliageHotReloadWarnedStructural = true;", " console.log(\"[Foliage hot-reload] Structural behavior change detected; reinitialize is not supported in v1.\");", " }", " }", - "", - " if (behavior.__foliageHotReloadRuntimeSig === undefined) {", - " behavior.__foliageHotReloadRuntimeSig = runtimeSig;", - " } else if (behavior.__foliageHotReloadRuntimeSig === runtimeSig) {", - " return false;", - " } else {", - " behavior.__foliageHotReloadRuntimeSig = runtimeSig;", - " }", + " if (!hotReload.runtimeChanged) return false;", "", " /** @type {Set} */", " var materials = new Set();", @@ -1381,6 +1495,8 @@ " if (result.fadeChanged) state._fadeParamsDirty = true;", " if (result.missingAutoSwayResponse) missingAuto = true;", " }", + " // A newly enabled world gradient must see transforms changed while it was off.", + " if (entry) nonInstanced.syncGradientEntry(entry);", " if (state._materialBuckets) state._materialBuckets.rebuildNeeded = true;", " if (missingAuto) {", " behavior.__foliageHotReloadStructuralDirty = true;", @@ -1393,6 +1509,36 @@ " }", "", " /**", + " * Include generated GLSL variants in Three.js's program key, not uniform values.", + " * Capture the unpatched default key; custom source keys stay live on clones.", + " * @param {THREE.Material} mat", + " * @param {string} patchKind", + " * @param {THREE.Material} [sourceMat]", + " * @returns {void}", + " */", + " function setFoliageProgramCacheKey(mat, patchKind, sourceMat) {", + " var source = sourceMat || mat;", + " var sourceKey = source.customProgramCacheKey;", + " var defaultKey = sourceKey === THREE.Material.prototype.customProgramCacheKey", + " ? sourceKey.call(source) : null;", + " var usesSway = patchKind === \"sway\" || patchKind === \"swayShadow\" || patchKind === \"plainSway\";", + " var conditionalFade = usesSway || patchKind === \"fadeOnly\" || patchKind === \"plainFade\";", + " var beautyPass = patchKind === \"sway\" || patchKind === \"plainSway\" || patchKind === \"fadeOnly\" || patchKind === \"plainFade\";", + " mat.customProgramCacheKey = function() {", + " var cfg = this.userData.foliageConfig;", + " return [", + " defaultKey !== null ? defaultKey : sourceKey.call(this),", + " \"FoliageShaderV3\",", + " patchKind,", + " usesSway && cfg ? cfg.swayType || \"\" : \"\",", + " usesSway && cfg && cfg.swayType !== \"TreeTrunk\" && !cfg.uniformSway ? 1 : 0,", + " conditionalFade ? (!cfg || cfg.distanceFadeEnabled ? 1 : 0) : 1,", + " beautyPass && cfg && cfg.customPBREnabled ? 1 : 0", + " ].join(\"|\");", + " };", + " }", + "", + " /**", " * Normalize editor culling enum values into one canonical runtime mode.", " * @param {FoliagePrimitiveValue} v", " * @returns {{ name: string, code: number }}", @@ -1431,12 +1577,12 @@ " }", "", " /**", - " * Build the cache-key suffix for shader-relevant custom PBR options.", + " * Keep distinct PBR values in distinct material batches; these are not shader variants.", " * @returns {string}", " */", " function buildPbrSuffix(customPBREnabledValue, metallicValue, roughnessValue, specularValue, normalStrengthValue, ambientOcclusionStrengthValue, environmentStrengthValue) {", " if (!customPBREnabledValue) return \"_CUSTOM_PBR0\";", - " return \"_CUSTOM_PBR1_METALLIC\" + Math.round(metallicValue * 100) + \"_ROUGHNESS\" + Math.round(roughnessValue * 100) + \"_SPECULAR\" + Math.round(specularValue * 100) + \"_NORMAL_STRENGTH\" + Math.round(normalStrengthValue * 100) + \"_AMBIENT_OCCLUSION\" + Math.round(ambientOcclusionStrengthValue * 100) + \"_ENVIRONMENT\" + Math.round(environmentStrengthValue * 100);", + " return \"_CUSTOM_PBR1_METALLIC\" + metallicValue + \"_ROUGHNESS\" + roughnessValue + \"_SPECULAR\" + specularValue + \"_NORMAL_STRENGTH\" + normalStrengthValue + \"_AMBIENT_OCCLUSION\" + ambientOcclusionStrengthValue + \"_ENVIRONMENT\" + environmentStrengthValue;", " }", "", " /**", @@ -1874,16 +2020,19 @@ "", " /** @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);", - " }", + " for (var entry of entries) this.syncGradientEntry(entry);", + " }", + "", + " /** @param {FoliageNonInstancedEntry} entry @returns {void} */", + " syncGradientEntry(entry) {", + " if (!entry.threeObj || !entry.material || !entry.gradientSources || !entry.gradientSources.length) return;", + " var cfg = entry.material.userData.foliageConfig;", + " if (!cfg || !cfg.gradientBounds || !cfg.useColorGradient || !cfg.ignoreUV) return;", + " entry.threeObj.updateWorldMatrix(true, true);", + " if (!refreshGradientBounds(entry.gradientSources, cfg.gradientBounds, this.state._inverseWorldScale)) return;", + " 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} */", @@ -1943,6 +2092,70 @@ " }", " }", "", + " /** @typedef {{ base: THREE.BufferGeometry, refs: number, retired: THREE.BufferGeometry|null }} FoliageSharedGeometryEntry */", + " /** @typedef {{ source: THREE.BufferGeometry, entry: FoliageSharedGeometryEntry, geometry: THREE.BufferGeometry, uploaded: boolean }} FoliageGeometryLease */", + " /** @typedef {{ centers: Float32Array, count: number, free: Set|undefined, freeCount: number, minX: number, minY: number, maxX: number, maxY: number }} FoliageDistanceBounds */", + "", + " /**", + " * Owns one immutable base-buffer copy per source and lightweight chunk wrappers.", + " * The first retired, uploaded wrapper stays as the GPU disposal owner until the", + " * last lease ends. This also covers a last chunk that has never been rendered.", + " */", + " class FoliageGeometryPool {", + " constructor() {", + " /** @type {Map} */", + " this.entries = new Map();", + " }", + "", + " /** @param {THREE.BufferGeometry} source @returns {FoliageGeometryLease} */", + " acquire(source) {", + " var entry = this.entries.get(source);", + " if (!entry) {", + " entry = { base: source.clone(), refs: 0, retired: null };", + " this.entries.set(source, entry);", + " }", + " var base = entry.base;", + " var geometry = new THREE.BufferGeometry();", + " geometry.setIndex(base.index);", + " for (var name of Object.keys(base.attributes)) geometry.setAttribute(name, base.attributes[name]);", + " geometry.morphAttributes = base.morphAttributes;", + " geometry.morphTargetsRelative = base.morphTargetsRelative;", + " for (var group of base.groups) geometry.addGroup(group.start, group.count, group.materialIndex);", + " geometry.setDrawRange(base.drawRange.start, base.drawRange.count);", + " geometry.boundingBox = base.boundingBox ? base.boundingBox.clone() : null;", + " geometry.boundingSphere = base.boundingSphere ? base.boundingSphere.clone() : null;", + " entry.refs++;", + " return { source: source, entry: entry, geometry: geometry, uploaded: false };", + " }", + "", + " /** @param {FoliageGeometryLease|null|undefined} lease */", + " release(lease) {", + " if (!lease) return;", + " var entry = lease.entry;", + " var geometry = lease.geometry;", + " entry.refs--;", + " if (entry.refs > 0 && lease.uploaded && !entry.retired) {", + " // One retained wrapper per base, not one per removed chunk.", + " entry.retired = geometry;", + " return;", + " }", + " if (entry.refs > 0 || entry.retired) {", + " // Three.js geometry disposal deletes every attached attribute buffer.", + " // Detach only shared base data; private instance attributes are released.", + " geometry.setIndex(null);", + " for (var name of Object.keys(entry.base.attributes)) geometry.deleteAttribute(name);", + " geometry.morphAttributes = {};", + " }", + " geometry.dispose();", + " if (entry.refs === 0) {", + " if (entry.retired) entry.retired.dispose();", + " entry.retired = null;", + " entry.base.dispose();", + " this.entries.delete(lease.source);", + " }", + " }", + " }", + "", " /**", " * Mutable storage for instanced foliage groups, pending queue items, and the", " * temporary math objects reused by the instancing pipeline.", @@ -1954,6 +2167,7 @@ " constructor(sceneTag) {", " /** @type {boolean} */", " this._isReady = false;", + " this.geometryPool = new FoliageGeometryPool();", " /** @type {Map} */", " this.groups = new Map();", " /** @type {Map>} */", @@ -2339,9 +2553,8 @@ " try {", " if (g.mesh) {", " if (g.mesh.parent) g.mesh.parent.remove(g.mesh);", - " if (g._ownedGeometry && typeof g._ownedGeometry.dispose === \"function\") {", - " try { g._ownedGeometry.dispose(); } catch (eGeo) {}", - " }", + " this.geometryPool.release(g._geometryLease);", + " g._geometryLease = null;", " if (g.mesh.dispose) {", " try { g.mesh.dispose(); } catch (eMesh) {}", " }", @@ -2351,6 +2564,8 @@ " g._ownedGeometry = null;", " g.matricesBuffer = null;", " g.centersXY = null;", + " g._distanceBounds = null;", + " g._distanceOutsideTicks = 0;", " g.centersZ = null;", " g.cullRadii = null;", " g.gradientBounds = null;", @@ -2417,6 +2632,12 @@ " g.freeIndexSet.add(instanceIndex);", " if (g.aliveCount > 0) g.aliveCount--;", " if (gLeaves && gLeaves.aliveCount > 0) gLeaves.aliveCount--;", + " g._distanceBoundsDirty = true;", + " g._distanceOutsideTicks = 0;", + " if (gLeaves) {", + " gLeaves._distanceBoundsDirty = true;", + " gLeaves._distanceOutsideTicks = 0;", + " }", " }", "", " if (g.aliveCount === 0) {", @@ -2455,17 +2676,14 @@ " */", " _refreshLiveFadeState(target, behavior, defaultEnabled) {", " if (!target) return;", - " var state = this.state;", " var enabled = (target.fadeEnabled === undefined) ? defaultEnabled : !!target.fadeEnabled;", " var start = target.fadeStart != null ? target.fadeStart : 1200;", " var end = target.fadeEnd != null ? target.fadeEnd : 1600;", " if (behavior) {", - " var fadePropsLive = state.getBehaviorProps(behavior);", - " if (fadePropsLive) {", - " enabled = !!fadePropsLive.distanceFadeEnabled;", - " start = fadePropsLive.fadeStart;", - " end = fadePropsLive.fadeEnd;", - " }", + " // Fade refresh needs only these three live values, not a full snapshot.", + " enabled = behavior._getUseDistanceFade();", + " start = readClamped(behavior._getDistanceFadeStart(), 1200, 0, 100000);", + " end = readClamped(behavior._getDistanceFadeEnd(), 1600, 0, 100000);", " }", " if (end <= start) end = start + 100;", " target.fadeEnabled = !!enabled;", @@ -2596,21 +2814,15 @@ " delete targetMat.userData.__gustVersionApplied;", " if (resolvedSide !== undefined && resolvedSide !== null) targetMat.side = resolvedSide;", " var signKey = detSign < 0 ? -1 : 1;", - " var sourceProgramKey = \"\";", - " if (typeof sourceMat.customProgramCacheKey === \"function\") {", - " try {", - " sourceProgramKey = String(sourceMat.customProgramCacheKey.call(sourceMat) || \"\");", - " } catch (eProgramKey) {", - " sourceProgramKey = \"\";", - " }", - " }", + " // Read the target's current config when a signed shadow is recompiled.", + " var sourceProgramKey = sourceMat.customProgramCacheKey;", " var patchKey = \"raw\";", " if (targetMat.userData.__foliageFadeOnlyShadowPatched) patchKey = \"fadeOnlyShadow\";", " else if (targetMat.userData.__foliageShadowPatched) patchKey = \"foliageShadow\";", " targetMat.customProgramCacheKey = function() {", " var sideKey = resolvedSide !== undefined && resolvedSide !== null ? resolvedSide : this.side;", " return [", - " sourceProgramKey,", + " sourceProgramKey.call(this),", " \"FoliageSignedShadow\",", " patchKey,", " signKey,", @@ -2653,6 +2865,7 @@ " if (typeof negMat.copy === \"function\") negMat.copy(sourceMat);", " negMat.name = (sourceMat.name || \"Foliage\") + \"::DetNeg\";", " negMat.onBeforeCompile = sourceMat.onBeforeCompile;", + " negMat.customProgramCacheKey = sourceMat.customProgramCacheKey;", " negMat.userData = Object.assign({}, sourceMat.userData || {});", " delete negMat.userData._foliageDetNegVariant;", " delete negMat.userData._foliageDepthMat;", @@ -2857,7 +3070,7 @@ " var centersRequired = slotCount * 2;", " if (!group.centersXY || group.centersXY.length < centersRequired) {", " var centersCurrentSize = group.centersXY ? group.centersXY.length : 0;", - " var centersNewSize = Math.max(centersRequired, Math.max(Math.floor(centersCurrentSize * 1.5), 512));", + " var centersNewSize = Math.max(centersRequired, Math.max(Math.floor(centersCurrentSize * 1.5), FOLIAGE_MIN_INSTANCE_CAPACITY * 2));", " var newCenters = new Float32Array(centersNewSize);", " if (group.centersXY) newCenters.set(group.centersXY);", " group.centersXY = newCenters;", @@ -2865,7 +3078,7 @@ "", " if (!group.centersZ || group.centersZ.length < slotCount) {", " var centersCurrentSizeZ = group.centersZ ? group.centersZ.length : 0;", - " var centersNewSizeZ = Math.max(slotCount, Math.max(Math.floor(centersCurrentSizeZ * 1.5), 256));", + " var centersNewSizeZ = Math.max(slotCount, Math.max(Math.floor(centersCurrentSizeZ * 1.5), FOLIAGE_MIN_INSTANCE_CAPACITY));", " var newCentersZ = new Float32Array(centersNewSizeZ);", " if (group.centersZ) newCentersZ.set(group.centersZ);", " group.centersZ = newCentersZ;", @@ -2873,12 +3086,14 @@ "", " if (!group.cullRadii || group.cullRadii.length < slotCount) {", " var radiiCurrentSize = group.cullRadii ? group.cullRadii.length : 0;", - " var radiiNewSize = Math.max(slotCount, Math.max(Math.floor(radiiCurrentSize * 1.5), 256));", + " var radiiNewSize = Math.max(slotCount, Math.max(Math.floor(radiiCurrentSize * 1.5), FOLIAGE_MIN_INSTANCE_CAPACITY));", " var newRadii = new Float32Array(radiiNewSize);", " if (group.cullRadii) newRadii.set(group.cullRadii);", " group.cullRadii = newRadii;", " }", "", + " group._distanceBoundsDirty = true;", + " group._distanceOutsideTicks = 0;", " group.centersXY[idx * 2] = isFinite(cx) ? cx : 0;", " group.centersXY[idx * 2 + 1] = isFinite(cy) ? cy : 0;", " group.centersZ[idx] = isFinite(cz) ? cz : 0;", @@ -3033,7 +3248,7 @@ " 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));", + " group.gradientBounds = new Float32Array(Math.max(required, previous ? previous.length * 2 : FOLIAGE_MIN_INSTANCE_CAPACITY * 2));", " if (previous) group.gradientBounds.set(previous);", " }", " if (!geometry.boundingBox) geometry.computeBoundingBox();", @@ -3111,6 +3326,18 @@ " }", "", " /**", + " * Resolve a stable cell after final scene transforms, before parking the owner.", + " * composeObjectWorldFromGDevelop stores translation in scene units.", + " * @param {THREE.Matrix4} objectMatrix", + " * @returns {string}", + " */", + " getChunkSuffix(objectMatrix) {", + " var x = Math.floor(objectMatrix.elements[12] / FOLIAGE_CHUNK_SIZE);", + " var y = Math.floor(objectMatrix.elements[13] / FOLIAGE_CHUNK_SIZE);", + " return \"::CHUNK_\" + FOLIAGE_CHUNK_SIZE + \"_\" + x + \"_\" + y;", + " }", + "", + " /**", " * Consume queued runtime objects, resolve their final instancing groups, and", " * write instance data into storage buffers without rebuilding render meshes yet.", " * @param {SharedFrameContext} ctx", @@ -3175,6 +3402,7 @@ " rootSet.forEach(function(r) {", " try { r.updateMatrixWorld(true); } catch (eRootUpdate) {}", " });", + " rootSet.clear();", "", " var itemsByParentRepMesh = inst._itemsByParentRepMesh;", " var repRelMatrixCache = inst._repRelMatrixCache;", @@ -3296,8 +3524,9 @@ " 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 chunkSuffixParts = this.getChunkSuffix(objW2P);", + " var groupKeyTrunk = part0.baseGroupKey + detSuffixParts + chunkSuffixParts;", + " var groupKeyLeaves = part1.baseGroupKey + detSuffixParts + chunkSuffixParts;", " var materialTrunk = this._getSignedInstancedMaterial(part0.material, detSignParts) || part0.material;", " var materialLeaves = this._getSignedInstancedMaterial(part1.material, detSignLeaves) || part1.material;", " this._logDetSignDiagnostic(\"split\", part0.baseGroupKey, detSignParts, behaviorParts);", @@ -3310,7 +3539,7 @@ " gTrunk.instanceDetSign = detSignParts;", " gTrunk.castShadow = !!gTrunk.castShadow || !!(repMeshP0 && repMeshP0.castShadow);", " gTrunk.receiveShadow = !!gTrunk.receiveShadow || !!(repMeshP0 && repMeshP0.receiveShadow);", - " if (typeof gTrunk.fadeEnabled !== \"boolean\" && behaviorParts) {", + " if (behaviorParts) {", " this._refreshLiveFadeState(gTrunk, behaviorParts, false);", " }", " if (behaviorParts) {", @@ -3328,7 +3557,7 @@ " if (!gLeaves.freeIndexSet || gLeaves.freeIndexSet !== gTrunk.freeIndexSet) gLeaves.freeIndexSet = gTrunk.freeIndexSet;", " gLeaves.castShadow = !!gLeaves.castShadow || !!(part1.repMesh && part1.repMesh.castShadow);", " gLeaves.receiveShadow = !!gLeaves.receiveShadow || !!(part1.repMesh && part1.repMesh.receiveShadow);", - " if (typeof gLeaves.fadeEnabled !== \"boolean\" && behaviorParts) {", + " if (behaviorParts) {", " this._refreshLiveFadeState(gLeaves, behaviorParts, false);", " }", " if (behaviorParts) {", @@ -3363,7 +3592,7 @@ " var requiredSizeP = Math.max(idxP + 1, gTrunk.matrixCount) * 16;", " if (!gTrunk.matricesBuffer || gTrunk.matricesBuffer.length < requiredSizeP) {", " var currentSizeP = gTrunk.matricesBuffer ? gTrunk.matricesBuffer.length : 0;", - " var newSizeP = Math.max(requiredSizeP, Math.max(Math.floor(currentSizeP * 1.5), 256 * 16));", + " var newSizeP = Math.max(requiredSizeP, Math.max(Math.floor(currentSizeP * 1.5), FOLIAGE_MIN_INSTANCE_CAPACITY * 16));", " var newBufferP = new Float32Array(newSizeP);", " if (gTrunk.matricesBuffer) newBufferP.set(gTrunk.matricesBuffer);", " gTrunk.matricesBuffer = newBufferP;", @@ -3388,7 +3617,7 @@ " var requiredSizeL = Math.max(idxP + 1, gLeaves.matrixCount) * 16;", " if (!gLeaves.matricesBuffer || gLeaves.matricesBuffer.length < requiredSizeL) {", " var currentSizeL = gLeaves.matricesBuffer ? gLeaves.matricesBuffer.length : 0;", - " var newSizeL = Math.max(requiredSizeL, Math.max(Math.floor(currentSizeL * 1.5), 256 * 16));", + " var newSizeL = Math.max(requiredSizeL, Math.max(Math.floor(currentSizeL * 1.5), FOLIAGE_MIN_INSTANCE_CAPACITY * 16));", " var newBufferL = new Float32Array(newSizeL);", " if (gLeaves.matricesBuffer) newBufferL.set(gLeaves.matricesBuffer);", " gLeaves.matricesBuffer = newBufferL;", @@ -3509,6 +3738,7 @@ " isSignedLeavesGroup = true;", " this._logDetSignDiagnostic(\"single\", baseGroupKey, detSignSingle, item.behavior);", " }", + " groupKey += this.getChunkSuffix(objW2);", " var g = inst.ensureGroup(groupKey, {", " key: groupKey,", " geometry: geometry,", @@ -3520,7 +3750,7 @@ " }", " g.castShadow = !!g.castShadow || !!(repMesh && repMesh.castShadow);", " g.receiveShadow = !!g.receiveShadow || !!(repMesh && repMesh.receiveShadow);", - " if (typeof g.fadeEnabled !== \"boolean\" && item.behavior) {", + " if (item.behavior) {", " this._refreshLiveFadeState(g, item.behavior, false);", " }", " if (item.behavior) {", @@ -3554,7 +3784,7 @@ " var requiredSize = Math.max(idx + 1, g.matrixCount) * 16;", " if (!g.matricesBuffer || g.matricesBuffer.length < requiredSize) {", " var currentSize = g.matricesBuffer ? g.matricesBuffer.length : 0;", - " var newSize = Math.max(requiredSize, Math.max(Math.floor(currentSize * 1.5), 256 * 16));", + " var newSize = Math.max(requiredSize, Math.max(Math.floor(currentSize * 1.5), FOLIAGE_MIN_INSTANCE_CAPACITY * 16));", " var newBuffer = new Float32Array(newSize);", " if (g.matricesBuffer) newBuffer.set(g.matricesBuffer);", " g.matricesBuffer = newBuffer;", @@ -3623,6 +3853,9 @@ " } catch (eHide) {}", " }", "", + " // Batch-only caches must not retain deleted runtime objects between flushes.", + " itemsByParentRepMesh.clear();", + " repRelMatrixCache.clear();", " inst.markDirty();", " }", "", @@ -3642,6 +3875,8 @@ " var g = group;", " if (!g || !g.parent || !g.geometry || !g.material || !g.matricesBuffer) return;", "", + " // Queue/lifecycle changes invalidate packed data even without distance fade.", + " g._packingDirty = true;", " var aliveCount = g.aliveCount || 0;", " var totalSlots = g.matrixCount || 0;", " if (aliveCount <= 0 || totalSlots <= 0) return;", @@ -3661,33 +3896,33 @@ " }", "", " if (!mesh) {", - " newCap = nextPow2(need);", - " var floor = need > 512 ? 4096 : 512;", - " if (newCap < floor) newCap = floor;", + " newCap = Math.max(FOLIAGE_MIN_INSTANCE_CAPACITY, nextPow2(need));", " shouldRecreate = true;", " } else if (need > cap) {", - " newCap = nextPow2(need);", - " if (cap > 0) newCap = Math.max(newCap, Math.floor(cap * 1.5));", - " var floor2 = need > 512 ? 4096 : 512;", - " if (newCap < floor2) newCap = floor2;", + " newCap = Math.max(FOLIAGE_MIN_INSTANCE_CAPACITY, nextPow2(need));", " shouldRecreate = true;", " }", "", " if (shouldRecreate) {", + " // Acquire first so capacity growth cannot delete shared GPU base buffers.", + " var lease = inst.geometryPool.acquire(g.geometry);", " try {", " if (mesh) {", " if (mesh.parent) mesh.parent.remove(mesh);", - " if (g._ownedGeometry && typeof g._ownedGeometry.dispose === \"function\") {", - " try { g._ownedGeometry.dispose(); } catch (eGeo) {}", - " }", + " inst.geometryPool.release(g._geometryLease);", + " g._geometryLease = null;", " try { if (mesh.dispose) mesh.dispose(); } catch (eMesh) {}", " }", " } catch (eDisposeMesh) {}", "", " try {", - " var clonedGeometry = g.geometry.clone();", + " var clonedGeometry = lease.geometry;", " mesh = new THREE.InstancedMesh(clonedGeometry, g.material, newCap);", - " mesh.frustumCulled = false;", + " mesh.instanceMatrix.onUpload(function() {", + " lease.uploaded = true;", + " clearUploadedFoliageRanges.call(this);", + " });", + " mesh.frustumCulled = true;", " mesh.matrixAutoUpdate = false;", " mesh.matrix.identity();", " mesh.name = \"FoliageInstanced::\" + (g.key || \"\");", @@ -3703,16 +3938,19 @@ "", " var fadeAttr = new THREE.InstancedBufferAttribute(new Float32Array(newCap), 1);", " fadeAttr.setUsage(THREE.DynamicDrawUsage);", + " fadeAttr.onUpload(clearUploadedFoliageRanges);", " for (var fi = 0; fi < newCap; fi++) fadeAttr.array[fi] = 1.0;", " mesh.geometry.setAttribute(\"aFade\", fadeAttr);", "", " var fadePrevAttr = new THREE.InstancedBufferAttribute(new Float32Array(newCap), 1);", " fadePrevAttr.setUsage(THREE.DynamicDrawUsage);", + " fadePrevAttr.onUpload(clearUploadedFoliageRanges);", " 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);", + " gradientAttr.onUpload(clearUploadedFoliageRanges);", " mesh.geometry.setAttribute(\"aFoliageGradientBounds\", gradientAttr);", "", " mesh.count = 0;", @@ -3721,10 +3959,16 @@ " if (attachParent) attachParent.add(mesh);", " g.mesh = mesh;", " g._ownedGeometry = clonedGeometry;", + " g._geometryLease = lease;", " g.capacity = newCap;", " g._srcGeometryRef = g.geometry;", - " g._fadeDisabledApplied = false;", " } catch (eRebuild) {", + " if (mesh) {", + " if (mesh.parent) mesh.parent.remove(mesh);", + " mesh.dispose();", + " }", + " inst.geometryPool.release(lease);", + " g._geometryLease = null;", " g.mesh = null;", " g._ownedGeometry = null;", " g.capacity = 0;", @@ -3754,8 +3998,63 @@ " }", "", " /**", - " * Owner of instanced foliage culling decisions and the camera snapshots needed", - " * to decide when distance or frustum work has to run again.", + " * Three.js uploads a new buffer in full, without consuming updateRanges.", + " * Clear ranges only after an actual upload, including that initial allocation.", + " * @this {THREE.BufferAttribute}", + " * @returns {void}", + " */", + " function clearUploadedFoliageRanges() {", + " this.clearUpdateRanges();", + " }", + "", + " /**", + " * Coalesce changed components into one upload span per owned instance attribute.", + " * Include pending ranges: a culled mesh may go many ticks without a GPU upload.", + " * @param {THREE.BufferAttribute|null|undefined} attribute", + " * @param {number} start", + " * @param {number} end Exclusive component offset.", + " * @returns {void}", + " */", + " function markFoliageUpdateRange(attribute, start, end) {", + " if (!attribute || start >= end) return;", + " var ranges = attribute.updateRanges;", + " if (ranges.length) {", + " for (var i = 0; i < ranges.length; i++) {", + " start = Math.min(start, ranges[i].start);", + " end = Math.max(end, ranges[i].start + ranges[i].count);", + " }", + " ranges[0].start = start;", + " ranges[0].count = end - start;", + " ranges.length = 1;", + " } else {", + " attribute.addUpdateRange(start, end - start);", + " }", + " attribute.needsUpdate = true;", + " }", + "", + " /** @typedef {{ position: THREE.BufferAttribute|THREE.InterleavedBufferAttribute, positionVersion: number, box: THREE.Box3, localZExtent: number }} FoliageGeometryBounds */", + " /** @typedef {{ geometry: THREE.BufferGeometry, geometryBounds: FoliageGeometryBounds, instanceMatrix: THREE.InstancedBufferAttribute, instanceMatrixVersion: number, count: number, box: THREE.Box3, sphere: THREE.Sphere, maxScale: number, maxCondition: number, localZExtent: number }} FoliagePackedBounds */", + "", + " /**", + " * Upper bound on the linear transform's largest singular value.", + " * Unlike max column length, this remains conservative with sheared matrices.", + " * @param {THREE.Matrix4} matrix", + " * @returns {number}", + " */", + " function foliageLinearNorm(matrix) {", + " var e = matrix.elements;", + " var xx = e[0] * e[0] + e[1] * e[1] + e[2] * e[2];", + " var yy = e[4] * e[4] + e[5] * e[5] + e[6] * e[6];", + " var zz = e[8] * e[8] + e[9] * e[9] + e[10] * e[10];", + " var xy = Math.abs(e[0] * e[4] + e[1] * e[5] + e[2] * e[6]);", + " var xz = Math.abs(e[0] * e[8] + e[1] * e[9] + e[2] * e[10]);", + " var yz = Math.abs(e[4] * e[8] + e[5] * e[9] + e[6] * e[10]);", + " return Math.sqrt(Math.max(xx + xy + xz, yy + xy + yz, zz + xz + yz));", + " }", + "", + " /**", + " * Owns distance selection, packed buffers, and conservative group bounds.", + " * Native Three.js camera/light frustums decide which groups each pass draws.", " */", " class FoliageCullingCoordinator {", " /**", @@ -3764,106 +4063,63 @@ " constructor(state) {", " /** @type {FoliageSceneState} */", " this.state = state;", - " /** @type {THREE.Vector3|null} */", - " this._tmpVec3InstancedCull = null;", - " /** @type {THREE.Sphere|null} */", - " this._tmpSphereInstancedCull = null;", - " /** @type {THREE.Vector3|null} */", - " this._tmpVec3InstancedCamDir = null;", - " /** @type {THREE.Vector3|null} */", - " this._tmpVec3FrustumCamDir = null;", - " /** @type {number|undefined} */", - " this._prevCullCamDirX = undefined;", - " /** @type {number|undefined} */", - " this._prevCullCamDirY = undefined;", - " /** @type {number|undefined} */", - " this._prevCullCamDirZ = undefined;", + " /** @type {WeakMap} */", + " this._geometryBounds = new WeakMap();", + " /** @type {WeakMap} */", + " this._packedBounds = new WeakMap();", + " this._boundsMatrix = new THREE.Matrix4();", + " this._boundsInverse = new THREE.Matrix4();", + " this._boundsBox = new THREE.Box3();", " /** @type {number|undefined} */", " this._prevCullCamX = undefined;", " /** @type {number|undefined} */", " this._prevCullCamY = undefined;", " /** @type {number|undefined} */", " this._prevCullCamZ = undefined;", - " /** @type {number|undefined} */", - " this._prevFrustumPassCamX = undefined;", - " /** @type {number|undefined} */", - " this._prevFrustumPassCamY = undefined;", - " /** @type {number|undefined} */", - " this._prevFrustumPassCamZ = undefined;", - " /** @type {number|undefined} */", - " this._prevFrustumPassCamDirX = undefined;", - " /** @type {number|undefined} */", - " this._prevFrustumPassCamDirY = undefined;", - " /** @type {number|undefined} */", - " this._prevFrustumPassCamDirZ = undefined;", - " this.reset();", " }", "", - " /**", - " * @returns {FoliageCullingCoordinator}", - " */", + " /** @returns {FoliageCullingCoordinator} */", " reset() {", - " this._tmpVec3InstancedCull = null;", - " this._tmpSphereInstancedCull = null;", - " this._tmpVec3InstancedCamDir = null;", - " this._tmpVec3FrustumCamDir = null;", - " this._prevCullCamDirX = undefined;", - " this._prevCullCamDirY = undefined;", - " this._prevCullCamDirZ = undefined;", + " this._geometryBounds = new WeakMap();", + " this._packedBounds = new WeakMap();", " this._prevCullCamX = undefined;", " this._prevCullCamY = undefined;", " this._prevCullCamZ = undefined;", - " this._prevFrustumPassCamX = undefined;", - " this._prevFrustumPassCamY = undefined;", - " this._prevFrustumPassCamZ = undefined;", - " this._prevFrustumPassCamDirX = undefined;", - " this._prevFrustumPassCamDirY = undefined;", - " this._prevFrustumPassCamDirZ = undefined;", " return this;", " }", "", " /**", - " * @param {SharedFrameContext|null|undefined} frameCam", + " * Cache live center bounds, not nominal grid cells or wind-expanded render bounds.", + " * Only mixed-distance chunks need per-instance distance tests.", + " * @param {FoliageInstancingGroup} group", + " * @returns {FoliageDistanceBounds}", " */", - " didFrustumCameraChange(frameCam) {", - " var cam = frameCam && frameCam.cam ? frameCam.cam : null;", - " if (!cam) return false;", - "", - " var camX = isFinite(frameCam.camX) ? frameCam.camX : cam.position.x;", - " var camY = isFinite(frameCam.camY) ? frameCam.camY : cam.position.y;", - " var camZ = isFinite(frameCam.camZ) ? frameCam.camZ : cam.position.z;", - " var prevCamX = isFinite(this._prevFrustumPassCamX) ? this._prevFrustumPassCamX : camX;", - " 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 = !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\") {", - " if (!this._tmpVec3FrustumCamDir) this._tmpVec3FrustumCamDir = new THREE.Vector3();", - " var camDir = this._tmpVec3FrustumCamDir;", - " try { cam.getWorldDirection(camDir); } catch (eDir) {}", - " var prevDirX = isFinite(this._prevFrustumPassCamDirX) ? this._prevFrustumPassCamDirX : camDir.x;", - " var prevDirY = isFinite(this._prevFrustumPassCamDirY) ? this._prevFrustumPassCamDirY : camDir.y;", - " var prevDirZ = isFinite(this._prevFrustumPassCamDirZ) ? this._prevFrustumPassCamDirZ : camDir.z;", - " var dirDelta = Math.abs(camDir.x - prevDirX) + Math.abs(camDir.y - prevDirY) + Math.abs(camDir.z - prevDirZ);", - " dirChanged = dirDelta > 0.001;", - " this._prevFrustumPassCamDirX = camDir.x;", - " this._prevFrustumPassCamDirY = camDir.y;", - " this._prevFrustumPassCamDirZ = camDir.z;", - " }", - "", - " return camMoved || dirChanged;", + " getDistanceBounds(group) {", + " var centers = group.centersXY;", + " var count = group.matrixCount || 0;", + " var free = group.freeIndexSet;", + " var freeCount = free ? free.size : 0;", + " var cached = group._distanceBounds;", + " if (cached && !group._distanceBoundsDirty && cached.centers === centers &&", + " cached.count === count && cached.free === free && cached.freeCount === freeCount) return cached;", + " var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;", + " for (var i = 0; i < count; i++) {", + " if (free && free.has(i)) continue;", + " var x = centers[i * 2], y = centers[i * 2 + 1];", + " minX = Math.min(minX, x); maxX = Math.max(maxX, x);", + " minY = Math.min(minY, y); maxY = Math.max(maxY, y);", + " }", + " cached = { centers: centers, count: count, free: free, freeCount: freeCount,", + " minX: minX, minY: minY, maxX: maxX, maxY: maxY };", + " group._distanceBounds = cached;", + " group._distanceBoundsDirty = false;", + " group._distanceOutsideTicks = 0;", + " return cached;", " }", "", " /**", " * Update per-instance fade values and candidate lists from camera distance.", - " * This pass prepares the compact instance set used later by frustum compaction.", + " * Camera rotation and projection do not affect this distance-only population.", " * @param {SharedFrameContext|null|undefined} ctx", " * @param {boolean} forceRun", " * @param {(target: FoliageInstancingGroup|FoliageNonInstancedEntry|null|undefined, behavior: FoliageBehavior|null|undefined, defaultEnabled: boolean) => void} refreshFadeState", @@ -3874,7 +4130,6 @@ " 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;", " var camZ = ctx && isFinite(ctx.camZ) ? ctx.camZ : 0;", @@ -3889,22 +4144,8 @@ " var camDelta = Math.abs(camX - prevCamX) + Math.abs(camY - prevCamY) + Math.abs(camZ - prevCamZ);", " var camMoved = !isFinite(this._prevCullCamX) || camDelta > 0.5 * inverseWorldScale;", "", - " var camDirChanged = false;", - " if (cam && typeof cam.getWorldDirection === \"function\" && typeof THREE.Vector3 !== \"undefined\") {", - " if (!this._tmpVec3InstancedCamDir) this._tmpVec3InstancedCamDir = new THREE.Vector3();", - " var camDir = this._tmpVec3InstancedCamDir;", - " try { cam.getWorldDirection(camDir); } catch (eDir) {}", - " var prevDirX = isFinite(this._prevCullCamDirX) ? this._prevCullCamDirX : camDir.x;", - " var prevDirY = isFinite(this._prevCullCamDirY) ? this._prevCullCamDirY : camDir.y;", - " var prevDirZ = isFinite(this._prevCullCamDirZ) ? this._prevCullCamDirZ : camDir.z;", - " var dirDelta = Math.abs(camDir.x - prevDirX) + Math.abs(camDir.y - prevDirY) + Math.abs(camDir.z - prevDirZ);", - " camDirChanged = dirDelta > 0.001;", - " this._prevCullCamDirX = camDir.x;", - " this._prevCullCamDirY = camDir.y;", - " this._prevCullCamDirZ = camDir.z;", - " }", - "", " var fadeParamsChanged = false;", + " var packingRequired = false;", " inst.groups.forEach(function(group) {", " /** @type {FoliageInstancingGroup} */", " var g = group;", @@ -3918,13 +4159,15 @@ " var newSig = (sigEnabled ? 1 : 0) + \"|\" + sigStart + \"|\" + sigEnd;", " if (g._fadeSig !== newSig) {", " g._fadeSig = newSig;", + " g._packingDirty = true;", " fadeParamsChanged = true;", " }", + " if (g.mesh && g._packingDirty !== false) packingRequired = true;", " });", " if (fadeParamsChanged) state._fadeParamsDirty = true;", "", " var hasPending = !!(inst.pending && inst.pending.length > 0);", - " if (!forceRun && !camMoved && !camDirChanged && !inst.dirty && !hasPending && !state._fadeParamsDirty) {", + " if (!forceRun && !camMoved && !inst.dirty && !hasPending && !packingRequired && !state._fadeParamsDirty) {", " return false;", " }", "", @@ -3933,71 +4176,33 @@ " this._prevCullCamY = camY;", " this._prevCullCamZ = camZ;", "", + " var forcePacking = forceRun || inst.dirty;", + " var didProcess = false;", + " var didFadeTick = false;", + " var self = this;", " inst.groups.forEach(function(group) {", " /** @type {FoliageInstancingGroup} */", " var g = group;", " if (!g || !g.mesh) return;", "", - " var mesh = g.mesh;", + " // Camera movement alone cannot change a fade-disabled group's population.", + " if (!g.fadeEnabled && g._packingDirty === false && !forcePacking) return;", + " g._packingDirty = true;", + " didProcess = true;", "", " if (!g.fadeEnabled) {", - " var fadeEnabledPrev = (typeof g._lastFadeEnabled === \"boolean\") ? g._lastFadeEnabled : false;", - " var justDisabled = fadeEnabledPrev && !g.fadeEnabled;", - " g._lastFadeEnabled = !!g.fadeEnabled;", " g._distanceCandidateCount = 0;", - " if (justDisabled || !g._fadeDisabledApplied) {", - " var aliveN = g.aliveCount || 0;", - " if (aliveN === 0) {", - " mesh.count = 0;", - " mesh.visible = false;", - " g.visibleCount = 0;", - " g._fadeDisabledApplied = true;", - " return;", - " }", - " var freeSet0 = g.freeIndexSet;", - " var totalSlots0 = g.matrixCount || 0;", - " var storMat0 = g.matricesBuffer;", - " var renderMat0 = mesh.instanceMatrix ? mesh.instanceMatrix.array : null;", - " var renderFade0 = mesh.geometry ? mesh.geometry.getAttribute(\"aFade\") : null;", - " var renderFadePrev0 = mesh.geometry ? mesh.geometry.getAttribute(\"aFadePrev\") : null;", - " if (!renderMat0 || !storMat0) { g._fadeDisabledApplied = true; return; }", - " var vis0 = 0;", - " for (var i0 = 0; i0 < totalSlots0; i0++) {", - " if (freeSet0 && freeSet0.has(i0)) continue;", - " var sOff0 = i0 * 16; var dOff0 = vis0 * 16;", - " renderMat0[dOff0] = storMat0[sOff0];", - " renderMat0[dOff0 + 1] = storMat0[sOff0 + 1];", - " renderMat0[dOff0 + 2] = storMat0[sOff0 + 2];", - " renderMat0[dOff0 + 3] = storMat0[sOff0 + 3];", - " renderMat0[dOff0 + 4] = storMat0[sOff0 + 4];", - " renderMat0[dOff0 + 5] = storMat0[sOff0 + 5];", - " renderMat0[dOff0 + 6] = storMat0[sOff0 + 6];", - " renderMat0[dOff0 + 7] = storMat0[sOff0 + 7];", - " renderMat0[dOff0 + 8] = storMat0[sOff0 + 8];", - " renderMat0[dOff0 + 9] = storMat0[sOff0 + 9];", - " renderMat0[dOff0 + 10] = storMat0[sOff0 + 10];", - " renderMat0[dOff0 + 11] = storMat0[sOff0 + 11];", - " renderMat0[dOff0 + 12] = storMat0[sOff0 + 12];", - " renderMat0[dOff0 + 13] = storMat0[sOff0 + 13];", - " renderMat0[dOff0 + 14] = storMat0[sOff0 + 14];", - " renderMat0[dOff0 + 15] = storMat0[sOff0 + 15];", - " if (renderFade0 && renderFade0.array) renderFade0.array[vis0] = 1.0;", - " if (renderFadePrev0 && renderFadePrev0.array) renderFadePrev0.array[vis0] = 1.0;", - " vis0++;", - " }", - " mesh.count = vis0;", - " mesh.instanceMatrix.needsUpdate = true;", - " if (renderFade0) renderFade0.needsUpdate = true;", - " if (renderFadePrev0) renderFadePrev0.needsUpdate = true;", - " mesh.visible = (vis0 > 0);", - " g.visibleCount = vis0;", - " g._fadeDisabledApplied = true;", + " // Re-enabling fade starts from the fully visible state just rendered.", + " if (g._lastFadeEnabled) {", + " if (g.instanceFade) g.instanceFade.fill(1);", + " if (g.instanceFadePrev) g.instanceFadePrev.fill(1);", " }", + " g._lastFadeEnabled = false;", + " g._distanceOutsideTicks = 0;", " return;", " }", - "", " g._lastFadeEnabled = true;", - " g._fadeDisabledApplied = false;", + " didFadeTick = true;", "", " if (!g.centersXY) {", " g._distanceCandidateCount = 0;", @@ -4015,14 +4220,21 @@ " var margin = 256 * inverseWorldScale;", " var endPlusMargin = endD + margin;", " var endPlusMarginSq = endPlusMargin * endPlusMargin;", + " var bounds = self.getDistanceBounds(g);", + " var nearX = Math.max(bounds.minX - camX, 0, camX - bounds.maxX);", + " var nearY = Math.max(bounds.minY - camY, 0, camY - bounds.maxY);", + " var outside = nearX * nearX + nearY * nearY > endPlusMarginSq;", + " var farX = Math.max(Math.abs(bounds.minX - camX), Math.abs(bounds.maxX - camX));", + " var farY = Math.max(Math.abs(bounds.minY - camY), Math.abs(bounds.maxY - camY));", + " var inside = farX * farX + farY * farY < startDSq;", "", " var oldFade = g.instanceFade;", " var oldPrev = g.instanceFadePrev;", " var needResize = !oldFade || !oldPrev || oldFade.length < totalSlots || oldPrev.length < totalSlots;", " if (needResize) {", " var newCap = Math.max(", - " totalSlots,", - " 256,", + " nextPow2(totalSlots),", + " FOLIAGE_MIN_INSTANCE_CAPACITY,", " oldFade ? oldFade.length : 0,", " oldPrev ? oldPrev.length : 0", " );", @@ -4040,6 +4252,12 @@ " g.instanceFadePrev = newPrev;", " }", "", + " if (outside && g._distanceOutsideTicks >= 2 && !needResize) {", + " // Both fade histories are zero; retain that state without scanning slots.", + " g._distanceCandidateCount = 0;", + " return;", + " }", + "", " var tmpSwap = g.instanceFadePrev;", " g.instanceFadePrev = g.instanceFade;", " g.instanceFade = tmpSwap;", @@ -4051,9 +4269,19 @@ " return;", " }", "", + " if (outside) {", + " for (var i = 0; i < totalSlots; i++) {", + " if (!freeSet || !freeSet.has(i)) storFade[i] = 0;", + " }", + " g._distanceOutsideTicks = (g._distanceOutsideTicks || 0) + 1;", + " g._distanceCandidateCount = 0;", + " return;", + " }", + " g._distanceOutsideTicks = 0;", + "", " var candidates = g._distanceCandidateIndices;", " if (!candidates || candidates.length < totalSlots) {", - " var candCap = Math.max(totalSlots, candidates ? candidates.length : 0, 256);", + " var candCap = Math.max(nextPow2(totalSlots), candidates ? candidates.length : 0, FOLIAGE_MIN_INSTANCE_CAPACITY);", " candidates = new Uint32Array(candCap);", " g._distanceCandidateIndices = candidates;", " }", @@ -4062,6 +4290,11 @@ " for (var i = 0; i < totalSlots; i++) {", " if (freeSet && freeSet.has(i)) continue;", "", + " if (inside) {", + " storFade[i] = 1;", + " candidates[candidateCount++] = i;", + " continue;", + " }", " var wx = storCenters[i * 2];", " var wy = storCenters[i * 2 + 1];", " var dx = wx - camX;", @@ -4094,154 +4327,260 @@ " g._distanceCandidateCount = candidateCount;", " });", "", - " state.lastCullTime = state.time;", + " if (didFadeTick) state.lastCullTime = state.time;", " state._fadeParamsDirty = false;", " inst.dirty = false;", - " return true;", + " return didProcess;", " }", "", " /**", - " * Compact instanced groups into their visible render buffers after the latest", - " * distance pass, preserving only instances that should stay renderable this frame.", - " * @param {SharedFrameContext|null|undefined} ctx", + " * Pack distance candidates (or all live slots with fade off) for BOTH passes.", + " * Three.js tests the resulting group against each camera independently.", + " * @returns {boolean}", " */", - " performFrustumCompactionPass(ctx) {", - " var state = this.state;", - " var inst = state.instancing;", + " compactDistanceCandidates() {", + " var inst = this.state.instancing;", " if (!inst || !inst.groups) return false;", - "", - " var frustumInstanced = ctx && ctx.frustum ? ctx.frustum : null;", - "", - " var tmpVec3InstancedCull = null;", - " if (typeof THREE.Vector3 !== \"undefined\") {", - " if (!this._tmpVec3InstancedCull) this._tmpVec3InstancedCull = new THREE.Vector3();", - " tmpVec3InstancedCull = this._tmpVec3InstancedCull;", - " }", - " var tmpSphereInstancedCull = null;", - " if (typeof THREE.Sphere !== \"undefined\") {", - " if (!this._tmpSphereInstancedCull) this._tmpSphereInstancedCull = new THREE.Sphere();", - " tmpSphereInstancedCull = this._tmpSphereInstancedCull;", - " }", - " var frustumRadiusScale = 1.10;", - " var frustumRadiusMargin = 32.0 * (ctx ? ctx.inverseWorldScale : 1);", - "", + " var self = this;", " var anyProcessed = false;", " inst.groups.forEach(function(group) {", " /** @type {FoliageInstancingGroup} */", " var g = group;", - " if (!g || !g.mesh) return;", + " if (!g || !g.mesh || g._packingDirty === false) return;", "", " var mesh = g.mesh;", - " anyProcessed = true;", "", " var fadeEnabled = !!g.fadeEnabled;", " var candidates = g._distanceCandidateIndices;", " var candidateCount = (typeof g._distanceCandidateCount === \"number\") ? g._distanceCandidateCount : 0;", " var totalSlots = g.matrixCount || 0;", " var freeSet = g.freeIndexSet;", - " if (fadeEnabled && (!candidates || candidateCount <= 0)) {", - " mesh.count = 0;", - " mesh.visible = false;", - " g.visibleCount = 0;", - " return;", - " }", - " if (!fadeEnabled && totalSlots <= 0) {", - " mesh.count = 0;", - " mesh.visible = false;", - " g.visibleCount = 0;", - " return;", - " }", - "", " var storMat = g.matricesBuffer;", " 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;", + " var renderFadeAttr = /** @type {THREE.InstancedBufferAttribute|undefined} */ (mesh.geometry.getAttribute(\"aFade\"));", + " var renderFadePrevAttr = /** @type {THREE.InstancedBufferAttribute|undefined} */ (mesh.geometry.getAttribute(\"aFadePrev\"));", + " var renderGradientAttr = /** @type {THREE.InstancedBufferAttribute|undefined} */ (mesh.geometry.getAttribute(\"aFoliageGradientBounds\"));", " var storFade = fadeEnabled ? g.instanceFade : null;", " var storFadePrev = fadeEnabled ? g.instanceFadePrev : null;", - " var fallbackCullRadius = (isFinite(g._instanceCullRadius) && g._instanceCullRadius > 0) ? g._instanceCullRadius : 0;", - " if (!renderMat || !storMat || !storCenters) return;", - " if (fadeEnabled && (!storFade || !storFadePrev)) return;", + " if (!renderMat || !storMat) return;", + " if (fadeEnabled && candidateCount > 0 && (!storFade || !storFadePrev)) return;", "", - " // Fade-enabled shadow casters stay on distance-only filtering so off-screen casters can still project visible shadows.", - " var useDistanceOnly = !!g.castShadow && !!fadeEnabled;", + " var matrixStart = Infinity, matrixEnd = 0;", + " var fadeStart = Infinity, fadeEnd = 0;", + " var fadePrevStart = Infinity, fadePrevEnd = 0;", + " var gradientStart = Infinity, gradientEnd = 0;", " var visibleCount = 0;", - " var iterCount = fadeEnabled ? candidateCount : totalSlots;", + " var iterCount = fadeEnabled ? (candidates ? candidateCount : 0) : totalSlots;", " for (var ci = 0; ci < iterCount; ci++) {", " var i = fadeEnabled ? candidates[ci] : ci;", - " if (!fadeEnabled && freeSet && freeSet.has(i)) continue;", + " if (freeSet && freeSet.has(i)) continue;", "", - " var wx = storCenters[i * 2];", - " var wy = storCenters[i * 2 + 1];", - " var wz = 0;", - " if (storCentersZ && i < storCentersZ.length) {", - " wz = storCentersZ[i];", - " } else {", - " var zOffDbg = i * 16 + 14;", - " if (zOffDbg < storMat.length) wz = storMat[zOffDbg];", - " }", - " if (!isFinite(wz)) wz = 0;", - "", - " var inFrustum = true;", - " if (!useDistanceOnly && frustumInstanced) {", - " var instanceCullRadius = (storRadii && i < storRadii.length && isFinite(storRadii[i]) && storRadii[i] > 0)", - " ? storRadii[i]", - " : fallbackCullRadius;", - " if (instanceCullRadius > 0 && tmpSphereInstancedCull && typeof frustumInstanced.intersectsSphere === \"function\") {", - " tmpSphereInstancedCull.center.set(wx, wy, wz);", - " tmpSphereInstancedCull.radius = instanceCullRadius * frustumRadiusScale + frustumRadiusMargin;", - " inFrustum = frustumInstanced.intersectsSphere(tmpSphereInstancedCull);", - " } else if (tmpVec3InstancedCull && typeof frustumInstanced.containsPoint === \"function\") {", - " tmpVec3InstancedCull.set(wx, wy, wz);", - " inFrustum = frustumInstanced.containsPoint(tmpVec3InstancedCull);", + " var sOff = i * 16; var dOff = visibleCount * 16;", + " for (var mi = 0; mi < 16; mi++) {", + " var value = storMat[sOff + mi];", + " if (renderMat[dOff + mi] !== value) {", + " renderMat[dOff + mi] = value;", + " if (matrixStart === Infinity) matrixStart = dOff + mi;", + " matrixEnd = dOff + mi + 1;", " }", " }", - "", - " if (!inFrustum) {", - " continue;", + " var fade = fadeEnabled ? storFade[i] : 1;", + " var fadePrev = fadeEnabled ? storFadePrev[i] : 1;", + " if (renderFadeAttr && renderFadeAttr.array[visibleCount] !== fade) {", + " renderFadeAttr.array[visibleCount] = fade;", + " if (fadeStart === Infinity) fadeStart = visibleCount;", + " fadeEnd = visibleCount + 1;", + " }", + " if (renderFadePrevAttr && renderFadePrevAttr.array[visibleCount] !== fadePrev) {", + " renderFadePrevAttr.array[visibleCount] = fadePrev;", + " if (fadePrevStart === Infinity) fadePrevStart = visibleCount;", + " fadePrevEnd = visibleCount + 1;", " }", - "", - " var sOff = i * 16; var dOff = visibleCount * 16;", - " renderMat[dOff] = storMat[sOff];", - " renderMat[dOff + 1] = storMat[sOff + 1];", - " renderMat[dOff + 2] = storMat[sOff + 2];", - " renderMat[dOff + 3] = storMat[sOff + 3];", - " renderMat[dOff + 4] = storMat[sOff + 4];", - " renderMat[dOff + 5] = storMat[sOff + 5];", - " renderMat[dOff + 6] = storMat[sOff + 6];", - " renderMat[dOff + 7] = storMat[sOff + 7];", - " renderMat[dOff + 8] = storMat[sOff + 8];", - " renderMat[dOff + 9] = storMat[sOff + 9];", - " renderMat[dOff + 10] = storMat[sOff + 10];", - " renderMat[dOff + 11] = storMat[sOff + 11];", - " renderMat[dOff + 12] = storMat[sOff + 12];", - " renderMat[dOff + 13] = storMat[sOff + 13];", - " renderMat[dOff + 14] = storMat[sOff + 14];", - " renderMat[dOff + 15] = storMat[sOff + 15];", - "", - " 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];", + " for (var gi = 0; gi < 2; gi++) {", + " var bound = g.gradientBounds[i * 2 + gi];", + " if (renderGradientAttr.array[visibleCount * 2 + gi] !== bound) {", + " renderGradientAttr.array[visibleCount * 2 + gi] = bound;", + " if (gradientStart === Infinity) gradientStart = visibleCount * 2 + gi;", + " gradientEnd = visibleCount * 2 + gi + 1;", + " }", + " }", " }", " visibleCount++;", " }", "", + " if (matrixStart < matrixEnd || mesh.count !== visibleCount) self._packedBounds.delete(mesh);", " mesh.count = visibleCount;", - " mesh.instanceMatrix.needsUpdate = true;", - " if (renderFadeAttr) renderFadeAttr.needsUpdate = true;", - " if (renderFadePrevAttr) renderFadePrevAttr.needsUpdate = true;", - " if (renderGradientAttr) renderGradientAttr.needsUpdate = true;", + " markFoliageUpdateRange(mesh.instanceMatrix, matrixStart, matrixEnd);", + " markFoliageUpdateRange(renderFadeAttr, fadeStart, fadeEnd);", + " markFoliageUpdateRange(renderFadePrevAttr, fadePrevStart, fadePrevEnd);", + " markFoliageUpdateRange(renderGradientAttr, gradientStart, gradientEnd);", " mesh.visible = (visibleCount > 0);", " g.visibleCount = visibleCount;", + " g._packingDirty = false;", + " anyProcessed = true;", " });", "", " return anyProcessed;", " }", + " /**", + " * Cache vertex bounds independently of which instances are currently packed.", + " * Geometry edits invalidate through position identity or needsUpdate/version.", + " * @param {THREE.BufferGeometry} geometry", + " * @returns {FoliageGeometryBounds|null}", + " */", + " getGeometryBounds(geometry) {", + " var position = geometry.getAttribute(\"position\");", + " if (!position) return null;", + " var positionVersion = position instanceof THREE.InterleavedBufferAttribute", + " ? position.data.version : position.version;", + " var cached = this._geometryBounds.get(geometry);", + " if (cached && cached.position === position && cached.positionVersion === positionVersion) return cached;", + "", + " geometry.computeBoundingBox();", + " if (!geometry.boundingBox) return null;", + " cached = {", + " position: position,", + " positionVersion: positionVersion,", + " box: geometry.boundingBox.clone(),", + " localZExtent: Math.max(Math.abs(geometry.boundingBox.min.z), Math.abs(geometry.boundingBox.max.z))", + " };", + " this._geometryBounds.set(geometry, cached);", + " return cached;", + " }", + "", + " /**", + " * Cache undeformed mesh-local bounds of the PACKED population, not capacity.", + " * Geometry is owned by the group; source resource bounds are not modified.", + " * @param {THREE.InstancedMesh} mesh", + " * @returns {FoliagePackedBounds|null}", + " */", + " getPackedBounds(mesh) {", + " var geometry = mesh.geometry;", + " var geometryBounds = this.getGeometryBounds(geometry);", + " if (!geometryBounds || geometryBounds.box.isEmpty()) return null;", + " var cached = this._packedBounds.get(mesh);", + " if (cached && cached.geometry === geometry && cached.geometryBounds === geometryBounds &&", + " cached.instanceMatrix === mesh.instanceMatrix && cached.instanceMatrixVersion === mesh.instanceMatrix.version &&", + " cached.count === mesh.count) return cached;", + " var box = new THREE.Box3();", + " var maxScale = 0;", + " var maxCondition = 0;", + " for (var i = 0; i < mesh.count; i++) {", + " mesh.getMatrixAt(i, this._boundsMatrix);", + " box.union(this._boundsBox.copy(geometryBounds.box).applyMatrix4(this._boundsMatrix));", + " var scale = foliageLinearNorm(this._boundsMatrix);", + " this._boundsInverse.copy(this._boundsMatrix).invert();", + " maxScale = Math.max(maxScale, scale);", + " // Gust displacement is inverse-transformed, then projected/bent locally.", + " maxCondition = Math.max(maxCondition, scale * foliageLinearNorm(this._boundsInverse));", + " }", + " cached = {", + " geometry: geometry,", + " geometryBounds: geometryBounds,", + " instanceMatrix: mesh.instanceMatrix,", + " instanceMatrixVersion: mesh.instanceMatrix.version,", + " count: mesh.count,", + " box: box,", + " sphere: box.getBoundingSphere(new THREE.Sphere()),", + " maxScale: maxScale,", + " maxCondition: maxCondition,", + " localZExtent: geometryBounds.localZExtent", + " };", + " this._packedBounds.set(mesh, cached);", + " return cached;", + " }", + "", + " /**", + " * Bound the current beauty/depth/distance vertex deformation for all phases.", + " * No texture readback is needed: the gust mask and tip masks are in [0, 1].", + " * @param {THREE.Material|null|undefined} material", + " * @param {FoliagePackedBounds} bounds", + " * @param {SharedFrameContext} ctx", + " * @param {number} worldToLocalScale", + " * @returns {number}", + " */", + " getSwayPadding(material, bounds, ctx, worldToLocalScale) {", + " /** @type {{ swayType?: string, swayResponse?: number }} */", + " var cfg = material && material.userData.foliageConfig || {};", + " if (!cfg.swayType) return 0; // Fade-only materials do not deform vertices.", + " var response = isFinite(cfg.swayResponse) ? cfg.swayResponse : 1;", + " var wind = Math.abs(ctx.windStrength);", + " var gust = this.state.gustEnabled ? Math.max(0, this.state.gustStrength) : 0;", + " var gustMul = 1 + gust * 0.35;", + " var localMove;", + " var worldPush = gust * wind * 0.6;", + " if (cfg.swayType === \"TreeTrunk\") {", + " // bend <= 1.3 * .4; side <= bend * .25 + twist (.18).", + " localMove = wind * Math.abs(response) * gustMul * (0.52 * 1.25 + 0.18);", + " worldPush *= 0.3;", + " } else {", + " // Base wave + flutter, with perpendicular side amplitude <= .30.", + " localMove = 1.2 * wind * Math.abs(response) * gustMul * Math.sqrt(1.09);", + " if (cfg.swayType === \"Bush\") localMove += 0.6 * wind * Math.abs(response) * gustMul;", + " if (cfg.swayType === \"Leaves\" && response >= 0.6) localMove += 0.4 * response * gustMul;", + " var bendMultiplier = cfg.swayType === \"Bush\" ? 0.1 : (cfg.swayType === \"Leaves\" ? 0.05 : 1);", + " var bendFactor = Math.max(0.2, Math.min(1.2, response * 0.4));", + " var maxBend = Math.min(0.5, gust * bendFactor * 0.5 * bendMultiplier);", + " localMove += maxBend * bounds.localZExtent;", + " }", + " return localMove * bounds.maxScale +", + " worldPush * ctx.inverseWorldScale * worldToLocalScale * bounds.maxCondition;", + " }", + "", + " /**", + " * Refresh padding cheaply each frame; scan instances only after packed data", + " * changes. Keep the base sphere separate so padding cannot accumulate.", + " * @param {SharedFrameContext} ctx", + " */", + " updateGroupBounds(ctx) {", + " var inst = this.state.instancing;", + " if (!inst || !inst.groups) return;", + " // These nodes use manual local matrices. Refresh world matrices before the", + " // first render too, so gust padding uses the actual parent/renderer scale.", + " if (inst.foliageRoot) {", + " inst.foliageRoot.matrixWorldNeedsUpdate = true;", + " inst.foliageRoot.updateWorldMatrix(true, false);", + " }", + " for (const group of inst.groups.values()) {", + " var mesh = group.mesh;", + " if (!mesh) continue;", + " if (mesh.count === 0) {", + " if (mesh.boundingSphere) mesh.boundingSphere.makeEmpty();", + " if (mesh.boundingBox) mesh.boundingBox.makeEmpty();", + " continue;", + " }", + " var bounds = this.getPackedBounds(mesh);", + " if (!bounds) {", + " mesh.frustumCulled = false;", + " continue;", + " }", + " mesh.matrixWorldNeedsUpdate = true;", + " mesh.updateWorldMatrix(true, false);", + " this._boundsInverse.copy(mesh.matrixWorld).invert();", + " var worldToLocalScale = foliageLinearNorm(this._boundsInverse);", + " var padding = Math.max(", + " this.getSwayPadding(group.material, bounds, ctx, worldToLocalScale),", + " this.getSwayPadding(mesh.customDepthMaterial, bounds, ctx, worldToLocalScale),", + " this.getSwayPadding(mesh.customDistanceMaterial, bounds, ctx, worldToLocalScale)", + " );", + " // Three.js scales spheres by max column length; compensate for parent shear.", + " var parentScale = mesh.matrixWorld.getMaxScaleOnAxis();", + " var shearCorrection = parentScale > 0 ? foliageLinearNorm(mesh.matrixWorld) / parentScale : 1;", + " var radius = (bounds.sphere.radius + padding) * shearCorrection;", + " if (!isFinite(radius)) {", + " mesh.frustumCulled = false;", + " continue;", + " }", + " if (!mesh.boundingSphere) mesh.boundingSphere = new THREE.Sphere();", + " if (!mesh.boundingBox) mesh.boundingBox = new THREE.Box3();", + " mesh.boundingSphere.copy(bounds.sphere);", + " mesh.boundingSphere.radius = radius;", + " mesh.boundingBox.copy(bounds.box).expandByScalar(padding);", + " mesh.frustumCulled = true;", + " }", + " }", + "", " }", "", " /**", @@ -4417,8 +4756,6 @@ " if (cacheObj._frameCtx) {", " cacheObj._frameCtx.runtimeScene = null;", " cacheObj._frameCtx.cam = null;", - " cacheObj._frameCtx.frustum = null;", - " cacheObj._frameCtx._frustumBuilt = false;", " cacheObj._frameCtx.camX = 0;", " cacheObj._frameCtx.camY = 0;", " cacheObj._frameCtx.camZ = 0;", @@ -4478,8 +4815,6 @@ " cacheObj.instancingState = new FoliageInstancingState(sceneTag);", " cacheObj.instancing = cacheObj.instancingState;", " }", - " cacheObj._frustumShared = null;", - " cacheObj._projViewMatrixShared = null;", " if (cacheObj.cullingCoordinator && typeof cacheObj.cullingCoordinator.reset === \"function\") {", " cacheObj.cullingCoordinator.reset();", " } else {", @@ -4535,17 +4870,13 @@ " cam: null,", " camX: 0,", " camY: 0,", - " camZ: 0,", - " frustum: null,", - " _frustumBuilt: false", + " camZ: 0", " };", " }", " 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;", " ", " var dt = runtimeScene.getTimeManager().getElapsedTime() / 1000.0;", " if (!isFinite(dt) || dt < 0) dt = 0;", @@ -4584,34 +4915,6 @@ " }", " ", " /**", - " * Lazily build the camera frustum once per frame context.", - " * @param {SharedFrameContext} ctx", - " * @returns {THREE.Frustum|null}", - " */", - " function ensureFrustum(ctx) {", - " var cache = /** @type {FoliageSceneState} */ (this);", - " if (ctx._frustumBuilt) return ctx.frustum;", - " ctx._frustumBuilt = true;", - " if (!ctx.cam) return null;", - " if (!cache._frustumShared) cache._frustumShared = new THREE.Frustum();", - " if (!cache._projViewMatrixShared) cache._projViewMatrixShared = new THREE.Matrix4();", - " try {", - " ctx.cam.updateMatrixWorld(true);", - " cache._projViewMatrixShared.multiplyMatrices(ctx.cam.projectionMatrix, ctx.cam.matrixWorldInverse);", - " var frustumBridge = /** @type {{ setFromProjectionMatrix?: (matrix: THREE.Matrix4) => void, setFromMatrix?: (matrix: THREE.Matrix4) => void }} */ (cache._frustumShared);", - " if (typeof frustumBridge.setFromProjectionMatrix === \"function\") {", - " frustumBridge.setFromProjectionMatrix(cache._projViewMatrixShared);", - " } else if (typeof frustumBridge.setFromMatrix === \"function\") {", - " frustumBridge.setFromMatrix(cache._projViewMatrixShared);", - " }", - " ctx.frustum = cache._frustumShared;", - " } catch (eFrustum) {", - " ctx.frustum = null;", - " }", - " return ctx.frustum;", - " }", - " ", - " /**", " * Scene-scoped root object for all mutable foliage runtime state.", " * Events fetch this once and then work through its owner registries and coordinators.", " */", @@ -4657,10 +4960,6 @@ " this._syncInstancingAliases();", " /** @type {SharedFrameContext|null} */", " this._frameCtx = null;", - " /** @type {THREE.Frustum|null} */", - " this._frustumShared = null;", - " /** @type {THREE.Matrix4|null} */", - " this._projViewMatrixShared = null;", " this._behaviorPropsCache = new WeakMap();", " /** @type {{ cx: number, cy: number, cz: number, radius: number }|undefined} */", " this._tmpInstanceCullSphere = undefined;", @@ -4715,6 +5014,8 @@ " inst._repRelByMesh = new WeakMap();", " inst._repRelMatrixCache.clear();", " for (const group of inst.groups.values()) {", + " group._distanceBoundsDirty = true;", + " group._distanceOutsideTicks = 0;", " var count = group.matrixCount || 0;", " for (var i = 0; i < count; i++) {", " if (group.centersXY) {", @@ -4818,7 +5119,6 @@ " disposeNonFallbackTex(tex) { return disposeNonFallbackTex.call(this, tex); }", " cleanupForSceneChange() { return cleanupForSceneChange.call(this); }", " buildFrameContext(runtimeScene, eventsFunctionContext) { return buildFrameContext.call(this, runtimeScene, eventsFunctionContext); }", - " _ensureFrustum(ctx) { return ensureFrustum.call(this, ctx); }", " }", " ", " /**", @@ -4857,6 +5157,9 @@ " buildSideSuffix: buildSideSuffix,", " buildPbrSuffix: buildPbrSuffix,", " buildGpuFastCacheKey: buildGpuFastCacheKey,", + " setFoliageProgramCacheKey: setFoliageProgramCacheKey,", + " applyFoliageNormalStrength: applyFoliageNormalStrength,", + " patchFoliagePbrShader: patchFoliagePbrShader,", " parseColor: parseColor,", " isAlphaLikely: isAlphaLikely,", " scoreMaterial: scoreMaterial,", @@ -4915,10 +5218,10 @@ " /** @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 {{ reset: () => FoliageCullingCoordinatorBridge, performDistanceCullTick: (ctx: SharedFrameContextBridge|null|undefined, forceRun: boolean, refreshFadeState: (target: FoliageFadeStateTargetBridge|null|undefined, behavior: FoliageBehaviorBridge|null|undefined, defaultEnabled: boolean) => void) => boolean, compactDistanceCandidates: () => boolean, updateGroupBounds: (ctx: SharedFrameContextBridge) => void }} 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, 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 {{ 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 }} 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, 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 */", " var gdjsFoliage = /** @type {FoliageGdjsBridge} */ (gdjs);", @@ -5085,28 +5388,18 @@ " var noActiveMats = !cache.activeMaterials || (typeof cache.activeMaterials.size === \"number\" && cache.activeMaterials.size === 0);", " if (noGroups && !hasNonInstanced && noActiveMats) return;", " ", - " // Capture camera once per frame; build the frustum only when a cull/frustum pass needs it.", + " // Reuse the frame camera for distance fade; native frustums belong to Three.js.", " // frameCam is just a nullable alias for ctx.", " var needFrameCam = shouldCull || arrNiLen > 0 || !noGroups;", " var frameCam = needFrameCam ? ctx : null;", " ", - " // Culling runs in two stages: distance first to update fade/candidate data,", - " // then frustum compaction to pack only the currently renderable instances.", - " var camChangedForFrustum = cullingOps.didFrustumCameraChange(frameCam);", - " var distanceTickRan = false;", - " if (shouldCull) {", - " distanceTickRan = cullingOps.performDistanceCullTick(ctx, !!hadDirty, refreshLiveFadeState);", - " }", - " ", - " var shouldRunFrustumPass = distanceTickRan || camChangedForFrustum;", - " if (shouldRunFrustumPass) {", - " cache._ensureFrustum(ctx);", - " }", - " ", - " if (shouldRunFrustumPass) {", - " cullingOps.performFrustumCompactionPass(ctx);", + " // Distance selection is shared by beauty and shadow passes. Camera rotation,", + " // FOV, zoom and roll no longer repack or upload instance buffers.", + " if (shouldCull && cullingOps.performDistanceCullTick(ctx, !!hadDirty, refreshLiveFadeState)) {", + " cullingOps.compactDistanceCandidates();", " }", - " ", + " cullingOps.updateGroupBounds(ctx);", + "", " // Parked non-instanced entries (fade disabled): periodically check if fade was re-enabled.", " if (arrNiStaticLen > 0) {", " nonInstanced.staticCheckAccum += dt;", @@ -5171,7 +5464,7 @@ " var enabledNi = (entryNi.fadeEnabled === undefined) ? true : !!entryNi.fadeEnabled;", " refreshLiveFadeState(entryNi, entryNi.fadeBehavior || null, true);", " enabledNi = !!entryNi.fadeEnabled;", - " // Optional perf path: when fade disabled, skip all distance/frustum work immediately and park entry.", + " // Optional perf path: when fade disabled, skip distance work immediately and park entry.", " if (!enabledNi) {", " applyFadeToMaterial(entryNi.material, 1.0);", " applyFadeToMaterial(entryNi.trunkMaterial, 1.0);", @@ -6623,7 +6916,8 @@ " /** @typedef {{ upsertFadeEntry: (entryData: FoliageNonInstancedEntryBridge) => FoliageNonInstancedEntryBridge|null }} FoliageNonInstancedRegistryBridge */", " /** @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, 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 {{ uniforms: { [name: string]: { value: unknown } }, fragmentShader: string }} FoliagePbrShaderBridge */", + " /** @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, applyFoliageNormalStrength: (mat: THREE.Material, strength: number) => void, patchFoliagePbrShader: (mat: THREE.Material, shader: FoliagePbrShaderBridge) => void, setFoliageProgramCacheKey: (mat: THREE.Material, patchKind: string, sourceMat?: THREE.Material) => void, 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, 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);", @@ -6679,7 +6973,10 @@ " var resolveRenderSide = foliageExt.resolveRenderSide;", " var buildSideSuffix = foliageExt.buildSideSuffix;", " var buildPbrSuffix = foliageExt.buildPbrSuffix;", + " var applyFoliageNormalStrength = foliageExt.applyFoliageNormalStrength;", + " var patchFoliagePbrShader = foliageExt.patchFoliagePbrShader;", " var buildGpuFastCacheKey = foliageExt.buildGpuFastCacheKey;", + " var setFoliageProgramCacheKey = foliageExt.setFoliageProgramCacheKey;", " var parseColor = foliageExt.parseColor;", " var isAlphaLikely = foliageExt.isAlphaLikely;", " var scoreMaterial = foliageExt.scoreMaterial;", @@ -6735,9 +7032,11 @@ " if (threeObjFast) {", " var inst = instancing;", " var groupKey = fastCached._gpuGroupKey;", - " var g = inst.groups.get(groupKey);", - " ", - " if (g && !(fastCached._resolvedSide !== undefined && g.material && typeof g.material.side === \"number\" && g.material.side !== fastCached._resolvedSide)) {", + " // The cache is asset-wide; spatial groups are resolved after final transforms.", + " var fastSharedEntry = cache.sharedByKey.get(fastCached._sharedMaterialKey);", + " var fastSharedMaterial = fastSharedEntry ? fastSharedEntry.material : null;", + "", + " if (fastSharedMaterial && !(fastCached._resolvedSide !== undefined && typeof fastSharedMaterial.side === \"number\" && fastSharedMaterial.side !== fastCached._resolvedSide)) {", " // Find the mesh using cached geometry (fast lookup).", " /** @type {FoliageMeshLike|null} */", " var repMeshFast = null;", @@ -6759,8 +7058,8 @@ " threeObj: threeObjFast,", " repMesh: repMeshFast,", " geometry: geoFast,", - " material: g.material,", - " parent: g.parent,", + " material: fastSharedMaterial,", + " parent: null,", " // flushPending may still derive the final runtime key from this base key.", " baseGroupKey: groupKey,", " swayType: swayTypeFast,", @@ -6799,7 +7098,13 @@ "", " if (swayTypeFast === \"Leaves\") {", " var materialNameFastLeaves = props.materialName.trim();", - " var fastCacheKeyLeaves = buildGpuFastCacheKey(sharedKeyFast, materialNameFastLeaves, swayTypeFast, distanceFadeFast, pbrSuffixFast, sideSuffixFast, twoSidedLightingFast, swayResponseAutoModeFast);", + " /** @type {FoliageObjectTypeCacheEntryBridge|undefined} */", + " var cachedRopFastLeaves = objectTypeCache.get(sharedKeyFast + \"::\" + materialNameFastLeaves);", + " var sourceSideFastLeaves = (cachedRopFastLeaves && cachedRopFastLeaves.selection && cachedRopFastLeaves.selection.srcRef)", + " ? cachedRopFastLeaves.selection.srcRef.side", + " : THREE.FrontSide;", + " var sideSuffixFastLeaves = buildSideSuffix(cullingModeFastInfo.code, resolveRenderSide(sourceSideFastLeaves, cullingModeFastInfo.name));", + " var fastCacheKeyLeaves = buildGpuFastCacheKey(sharedKeyFast, materialNameFastLeaves, swayTypeFast, distanceFadeFast, pbrSuffixFast, sideSuffixFastLeaves, twoSidedLightingFast, swayResponseAutoModeFast);", " /** @type {FoliageObjectTypeCacheEntryBridge|undefined} */", " var fastCachedLeaves = objectTypeCache.get(fastCacheKeyLeaves);", " if (fastCachedLeaves && fastCachedLeaves._gpuFastMode === \"splitLeavesTree\" &&", @@ -6852,7 +7157,7 @@ " behavior.__foliageQueued = true;", " behavior.__foliageQueueId = queueIdLeaves;", " delete behavior.__foliageNonInstancedRegistered;", - " // Split-tree cache stores base keys only; flushPending adds any determinant-sign suffix.", + " // Split-tree cache stores base keys; flushPending adds determinant signs and the spatial cell.", " behavior.__foliageSharedKey = fastCachedLeaves._gpuLeavesSharedMaterialKey;", " behavior.__foliageSharedKeyTrunk = fastCachedLeaves._gpuTrunkSharedMaterialKey;", " threeObjFastLeaves.userData = threeObjFastLeaves.userData || {};", @@ -7561,26 +7866,9 @@ " `;", " }", " ", + " // Color grading runs after alpha rejection; fade stays at color_fragment.", " function buildColorFragment() {", " return `", - " #include ", - " ", - " // Distance fade: Dither dissolve (screen-door effect) - check early to save color processing", - " // vFade: 1.0 = fully visible, 0.0 = fully invisible", - " ", - " // Early-out: if completely invisible, discard immediately (saves all color processing)", - " if (vFade < 0.01) discard;", - " ", - " // Interleaved Gradient Noise (IGN) - blue noise-like quality without texture", - " // Used in AAA games (Activision, etc.) - much smoother than white noise hash", - " vec2 screenPos = gl_FragCoord.xy;", - " float noise = fract(52.9829189 * fract(dot(screenPos, vec2(0.06711056, 0.00583715))));", - " ", - " // Dither threshold: discard pixel if noise > fade", - " // This creates gradual dissolve without alpha blending/sorting issues", - " if (vFade < 0.999 && noise > vFade) discard;", - " ", - " // Color processing (only executed if pixel passed dither check)", " vec3 baseCol = diffuseColor.rgb;", " if (uUseColorGradient > 0.5) {", " baseCol = mix(uGradientBottomColor, uGradientTopColor, clamp(vGrad, 0.0, 1.0));", @@ -7594,25 +7882,7 @@ " diffuseColor.rgb = baseCol;", " `;", " }", - " ", - " function buildColorFragmentNoFade() {", - " return `", - " #include ", - " ", - " vec3 baseCol = diffuseColor.rgb;", - " if (uUseColorGradient > 0.5) {", - " baseCol = mix(uGradientBottomColor, uGradientTopColor, clamp(vGrad, 0.0, 1.0));", - " }", - " ", - " float luma = dot(baseCol, vec3(0.2126, 0.7152, 0.0722));", - " baseCol = mix(vec3(luma), baseCol, uColorSaturation);", - " baseCol = (baseCol - 0.5) * uColorContrast + 0.5;", - " baseCol = clamp(baseCol, 0.0, 1.0);", - " ", - " diffuseColor.rgb = baseCol;", - " `;", - " }", - " ", + "", " /**", " * Build the shadow-pass vertex transform body for the current foliage mode.", " * @param {FoliageBehaviorPropsBridge} cfg", @@ -7832,6 +8102,7 @@ " if (shadowMat.userData.__foliageShadowPatched) return;", " ", " shadowMat.userData.__foliageShadowPatched = true;", + " setFoliageProgramCacheKey(shadowMat, \"swayShadow\");", " var originalOnBeforeCompile = shadowMat.onBeforeCompile;", " shadowMat.onBeforeCompile = function(shader) {", " if (originalOnBeforeCompile) originalOnBeforeCompile.call(this, shader);", @@ -7870,10 +8141,9 @@ " shader.uniforms.uUseWorldGradient = { value: cfgShadow.ignoreUV ? 1.0 : 0.0 };", " shader.uniforms.uGradientLocalZMin = { value: isFinite(cfgShadow.gradientLocalZMin) ? cfgShadow.gradientLocalZMin : -0.5 };", " shader.uniforms.uGradientLocalZMax = { value: isFinite(cfgShadow.gradientLocalZMax) ? cfgShadow.gradientLocalZMax : 0.5 };", - " if (useShadowFade) {", - " shader.uniforms.uDistanceFade = { value: pendingShadowFade };", - " shader.uniforms.uFadeInterpolation = { value: 1.0 };", - " }", + " // A cached fade variant may be reused without calling onBeforeCompile.", + " shader.uniforms.uDistanceFade = { value: pendingShadowFade };", + " shader.uniforms.uFadeInterpolation = { value: 1.0 };", "", " // Cached plain/instanced programs can be reused without onBeforeCompile.", " // Keep gust uniforms available even when USE_GUST strips the shader branch.", @@ -8082,9 +8352,9 @@ " depthPacking: THREE.RGBADepthPacking,", " map: mainMat.map || null,", " alphaMap: mainMat.alphaMap || null,", - " alphaTest: isFinite(mainMat.alphaTest) ? mainMat.alphaTest : 0,", " side: mainMat.side", " });", + " shadowDepth.alphaTest = isFinite(mainMat.alphaTest) ? mainMat.alphaTest : 0;", " shadowDepth.name = (mainMat.name || \"Foliage\") + \"::ShadowDepth\";", " mainMat.userData._foliageDepthMat = shadowDepth;", " mainMat.userData._foliageShadowOwned = true;", @@ -8099,9 +8369,9 @@ " if (!shadowDistance) {", " shadowDistance = new THREE.MeshDistanceMaterial({", " map: mainMat.map || null,", - " alphaMap: mainMat.alphaMap || null,", - " alphaTest: isFinite(mainMat.alphaTest) ? mainMat.alphaTest : 0", + " alphaMap: mainMat.alphaMap || null", " });", + " shadowDistance.alphaTest = isFinite(mainMat.alphaTest) ? mainMat.alphaTest : 0;", " shadowDistance.side = mainMat.side;", " shadowDistance.name = (mainMat.name || \"Foliage\") + \"::ShadowDistance\";", " mainMat.userData._foliageDistanceMat = shadowDistance;", @@ -8129,6 +8399,7 @@ " if (shadowMat.userData.__foliageFadeOnlyShadowPatched) return;", "", " shadowMat.userData.__foliageFadeOnlyShadowPatched = true;", + " setFoliageProgramCacheKey(shadowMat, \"fadeShadow\");", " var originalOnBeforeCompile = shadowMat.onBeforeCompile;", " shadowMat.onBeforeCompile = function(shader) {", " if (originalOnBeforeCompile) originalOnBeforeCompile.call(this, shader);", @@ -8195,9 +8466,9 @@ " depthPacking: THREE.RGBADepthPacking,", " map: mainMat.map || null,", " alphaMap: mainMat.alphaMap || null,", - " alphaTest: isFinite(mainMat.alphaTest) ? mainMat.alphaTest : 0,", " side: mainMat.side", " });", + " shadowDepth.alphaTest = isFinite(mainMat.alphaTest) ? mainMat.alphaTest : 0;", " shadowDepth.name = (mainMat.name || \"Foliage\") + \"::FadeOnlyShadowDepth\";", " mainMat.userData._foliageDepthMat = shadowDepth;", " mainMat.userData._foliageShadowOwned = true;", @@ -8212,9 +8483,9 @@ " if (!shadowDistance) {", " shadowDistance = new THREE.MeshDistanceMaterial({", " map: mainMat.map || null,", - " alphaMap: mainMat.alphaMap || null,", - " alphaTest: isFinite(mainMat.alphaTest) ? mainMat.alphaTest : 0", + " alphaMap: mainMat.alphaMap || null", " });", + " shadowDistance.alphaTest = isFinite(mainMat.alphaTest) ? mainMat.alphaTest : 0;", " shadowDistance.side = mainMat.side;", " shadowDistance.name = (mainMat.name || \"Foliage\") + \"::FadeOnlyShadowDistance\";", " mainMat.userData._foliageDistanceMat = shadowDistance;", @@ -8254,9 +8525,7 @@ " if (typeof mat.specularIntensity !== \"undefined\") mat.specularIntensity = specularValue;", " if (typeof mat.aoMapIntensity !== \"undefined\") mat.aoMapIntensity = ambientOcclusionStrengthValue;", " if (typeof mat.envMapIntensity !== \"undefined\") mat.envMapIntensity = environmentStrengthValue;", - " if (mat.normalScale && typeof mat.normalScale.set === \"function\") {", - " mat.normalScale.set(normalStrengthValue, normalStrengthValue);", - " }", + " applyFoliageNormalStrength(mat, normalStrengthValue);", " }", " ", " /**", @@ -8282,12 +8551,14 @@ " mat.side = cfg.renderSide;", " }", " ", + " setFoliageProgramCacheKey(mat, \"sway\");", " var originalOnBeforeCompile = mat.onBeforeCompile;", " ", " mat.onBeforeCompile = function (shader) {", " if (originalOnBeforeCompile) originalOnBeforeCompile.call(this, shader);", " ", " var cfg2 = this.userData.foliageConfig;", + " patchFoliagePbrShader(this, shader);", " var gustTex = cache.gustTexture || cache.gustFallbackTex;", " ", " // A recompiled variant must start with the current frame, not reset wind values.", @@ -8419,13 +8690,18 @@ " varying float vFade;", " `;", " var useDistanceFadeShader = cfg2 ? !!cfg2.distanceFadeEnabled : true;", - " var colorFragment = useDistanceFadeShader ? buildColorFragment() : buildColorFragmentNoFade();", + " var fadeFragment = useDistanceFadeShader ? buildFadeOnlyFragment() : buildFadeOnlyFragmentNoFade();", " shader.fragmentShader = shader.fragmentShader", " .replace(", " \"#include \",", " commonFragment", " )", - " .replace(\"#include \", colorFragment);", + " .replace(\"#include \", fadeFragment);", + " // Built-in materials reject transparent texels before grading their color.", + " // Without that chunk, retain the original fade-before-color ordering.", + " var colorAnchor = shader.fragmentShader.indexOf(\"#include \") !== -1", + " ? \"#include \" : fadeFragment;", + " shader.fragmentShader = shader.fragmentShader.replace(colorAnchor, colorAnchor + buildColorFragment());", " ", " if (cfg2) {", " var normalLightPatch = \"normal *= uInstanceDeterminantSign;\";", @@ -8461,10 +8737,12 @@ " if (cfgFadeOnly && typeof cfgFadeOnly.renderSide === \"number\" && mat.side !== cfgFadeOnly.renderSide) {", " mat.side = cfgFadeOnly.renderSide;", " }", + " setFoliageProgramCacheKey(mat, \"fadeOnly\");", " var originalOnBeforeCompile = mat.onBeforeCompile;", " mat.onBeforeCompile = function (shader) {", " if (originalOnBeforeCompile) originalOnBeforeCompile.call(mat, shader);", " var cfgFadeOnly2 = this.userData && this.userData.foliageConfig ? this.userData.foliageConfig : cfgFadeOnly;", + " patchFoliagePbrShader(this, shader);", " if (!shader.defines) shader.defines = {};", " // GPU fade smoothing: aFadePrev + uFadeInterpolation", " shader.uniforms.uFadeInterpolation = { value: 1.0 };", @@ -8769,7 +9047,7 @@ " 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.", + " // Cache the asset/mesh identity; flushPending adds the spatial cell to the group key.", " var trunkPickedId = selectionTrunk && selectionTrunk.pickedId ? String(selectionTrunk.pickedId) : \"\";", " var leavesPickedId = selection && selection.pickedId ? String(selection.pickedId) : \"\";", " var geoKeyTrunk = trunkPickedId", @@ -8861,7 +9139,7 @@ " } else {", " geo = objectTypeCache.getRenderGeometry(repMesh, selection);", " {", - " // Global instancing: geoKey stays a stable asset+mesh identity without runtime-instance suffixes.", + " // Cache the asset/mesh identity; flushPending adds the spatial cell to the group key.", " var materialPickedId = selection && selection.pickedId ? String(selection.pickedId) : \"\";", " var geoKey = materialPickedId", " ? (sanitizeKeyPart(sharedKey) + \"::\" + sanitizeKeyPart(repMesh.name || \"(unnamed)\") + \"::\" + sanitizeKeyPart(materialPickedId))", @@ -8869,16 +9147,8 @@ " var geoGroupKey = key + \"::GEOMETRY::\" + geoKey;", " ", " var inst = instancing;", - " var g = inst.ensureGroup(geoGroupKey, {", - " key: geoGroupKey,", - " geometry: geo,", - " material: sharedMat,", - " parent: null, // FoliageRoot will be attached during flushPending.", - " fadeEnabled: distanceFadeEnabled,", - " fadeStart: fadeStart,", - " fadeEnd: fadeEnd", - " });", - " ", + " // Do not create an empty global group: flushPending chooses the spatial cell.", + "", " // Fast cache entry for subsequent objects of same type", "\t var fastCacheKey = buildGpuFastCacheKey(sharedKey, materialName || \"\", swayType, distanceFadeEnabled, pbrSuffix, sideSuffix, twoSidedLighting, swayResponseAutoMode);", "\t if (!objectTypeCache.get(fastCacheKey)) {", @@ -8924,7 +9194,8 @@ " if (!gpuInstancing || (swayType !== \"Grass\" && swayType !== \"Bush\" && swayType !== \"TreeTrunk\" && swayType !== \"Leaves\")) {", " var matToAssign = sharedMat;", " var useNonInstancedFade = distanceFadeEnabled && sharedMat.userData && sharedMat.userData.foliageConfig;", - " var useSplitLeavesFade = !!(useNonInstancedFade && swayType === \"Leaves\");", + " var trunkUsesCustomPBR = !!(customPBREnabled && swayType === \"Leaves\" && rop && rop.records && rop.records.length === 2);", + " var useSplitLeavesMaterials = swayType === \"Leaves\" && (!!useNonInstancedFade || trunkUsesCustomPBR);", " var initialNonInstancedFade = useNonInstancedFade", " ? computeInitialNonInstancedFade(gdObj, threeObj, fadeStart, fadeEnd)", " : 1.0;", @@ -8945,12 +9216,14 @@ " 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.", + " setFoliageProgramCacheKey(fadeMat, \"plainSway\", sharedMat);", " 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;", + " // Keep the uniform available when returning to a cached fade program.", " shader.uniforms.uDistanceFade = { value: pendingFade };", + " if (!fadeMat.userData.foliageConfig.distanceFadeEnabled) return;", " var hasUFadeUniform = /uniform\\s+float\\s+uDistanceFade\\s*;/.test(shader.vertexShader);", " if (!hasUFadeUniform) {", " if (shader.vertexShader.indexOf(\"varying float vFade;\") !== -1) {", @@ -8969,7 +9242,7 @@ " if (shader.fragmentShader.indexOf(\"varying float vFade;\") === -1) {", " shader.fragmentShader = shader.fragmentShader.replace(\"#include \", \"#include \\nvarying float vFade;\");", " }", - " shader.fragmentShader = shader.fragmentShader.replace(\"#include \", buildColorFragment());", + " shader.fragmentShader = shader.fragmentShader.replace(\"#include \", buildFadeOnlyFragment());", " }", " fadeMat.userData.foliageUniforms = shader.uniforms;", " };", @@ -8997,7 +9270,7 @@ " if (!mSingle2) return;", "", " var matchSingle2 = matchesSelectedMaterial(mSingle2);", - " if (useSplitLeavesFade && !matchSingle2) {", + " if (useSplitLeavesMaterials && !matchSingle2) {", " if (trunkMatFound === null) trunkMatFound = mSingle2;", " if (trunkMatFound === mSingle2) trunkMeshes.push(mesh);", " }", @@ -9019,7 +9292,9 @@ " var matchA = matchesSelectedMaterial(m2);", " if (matchA) {", " needsChange2 = true;", - " break;", + " } else if (useSplitLeavesMaterials) {", + " if (trunkMatFound === null) trunkMatFound = m2;", + " if (trunkMatFound === m2 && trunkMeshes.indexOf(mesh) === -1) trunkMeshes.push(mesh);", " }", " }", " ", @@ -9038,19 +9313,33 @@ " mesh.material = newMats2;", " });", " {", - " // Leaves two-part: trunk must fade with leaves (same uDistanceFade).", - " if (useSplitLeavesFade && trunkMatFound) {", + " // Keep two-part trunk PBR independent of distance fade, with its own live config.", + " if (useSplitLeavesMaterials && trunkMatFound) {", " /** @type {THREE.Material} */", " var trunkFadeMat = trunkMatFound.clone();", " trunkFadeMat.userData = trunkFadeMat.userData || {};", + " trunkFadeMat.userData.foliageConfig = {", + " distanceFadeEnabled: !!distanceFadeEnabled,", + " customPBREnabled: trunkUsesCustomPBR,", + " metallic: metallic,", + " roughness: roughness,", + " specular: specular,", + " normalStrength: normalStrength,", + " ambientOcclusionStrength: ambientOcclusionStrength,", + " environmentStrength: environmentStrength", + " };", " trunkFadeMat.side = /** @type {THREE.Side} */ (resolveRenderSide(trunkMatFound.side, cullingMode));", - " applyCustomPBRIfSupported(trunkFadeMat, customPBREnabled, metallic, roughness, specular, normalStrength, ambientOcclusionStrength, environmentStrength);", + " applyCustomPBRIfSupported(trunkFadeMat, trunkUsesCustomPBR, metallic, roughness, specular, normalStrength, ambientOcclusionStrength, environmentStrength);", " applyPendingFadeToMaterial(trunkFadeMat, initialNonInstancedFade);", + " setFoliageProgramCacheKey(trunkFadeMat, \"plainFade\", trunkMatFound);", " var trunkOriginal = trunkMatFound.onBeforeCompile;", " trunkFadeMat.onBeforeCompile = function (shader) {", " if (trunkOriginal) trunkOriginal.call(trunkFadeMat, shader);", + " patchFoliagePbrShader(trunkFadeMat, shader);", " var pendingTrunk = (trunkFadeMat.userData && typeof trunkFadeMat.userData._pendingFade === \"number\") ? trunkFadeMat.userData._pendingFade : 1.0;", " shader.uniforms.uDistanceFade = { value: pendingTrunk };", + " trunkFadeMat.userData.foliageUniforms = shader.uniforms;", + " if (!trunkFadeMat.userData.foliageConfig.distanceFadeEnabled) return;", " var hasUFadeUniform = /uniform\\s+float\\s+uDistanceFade\\s*;/.test(shader.vertexShader);", " if (!hasUFadeUniform) {", " if (shader.vertexShader.indexOf(\"varying float vFade;\") !== -1) {", @@ -9075,8 +9364,12 @@ " ensureFadeOnlyShadowMaterials(trunkFadeMat);", " for (var trunkMeshIndex = 0; trunkMeshIndex < trunkMeshes.length; trunkMeshIndex++) {", " var trunkMesh = trunkMeshes[trunkMeshIndex];", - " if (!trunkMesh || !trunkMesh.material || Array.isArray(trunkMesh.material)) continue;", - " if (trunkMesh.material === trunkMatFound) {", + " if (!trunkMesh || !trunkMesh.material) continue;", + " if (Array.isArray(trunkMesh.material)) {", + " trunkMesh.material = trunkMesh.material.map(function(material) {", + " return material === trunkMatFound ? trunkFadeMat : material;", + " });", + " } else if (trunkMesh.material === trunkMatFound) {", " trunkMesh.material = trunkFadeMat;", " assignShadowMaterialsToMesh(trunkMesh, trunkFadeMat);", " }", @@ -9349,7 +9642,7 @@ "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @file doStepPostEvents - Applies runtime-safe behavior hot-reload changes. */", - "/** @typedef {{ __foliageHotReloadRuntimeSig?: string, __foliageHotReloadStructuralSig?: string, __foliageHotReloadStructuralDirty?: boolean, __foliageHotReloadWarnedStructural?: boolean }} FoliageBehaviorPrivateFieldsBridge */", + "/** @typedef {{ __foliageHotReloadStructuralDirty?: boolean, __foliageHotReloadWarnedStructural?: boolean }} FoliageBehaviorPrivateFieldsBridge */", "/** @typedef {gdjs.RuntimeBehavior & FoliageBehaviorPrivateFieldsBridge} FoliageBehaviorBridge */", "/** @typedef {{ _libReady?: boolean, syncObjectHotReload: (gdObj: (gdjs.RuntimeObject3D & { get3DRendererObject?: () => THREE.Object3D|null, getName?: () => string })|null|undefined, behavior: FoliageBehaviorBridge|null|undefined, behaviorName?: string) => boolean }} FoliageSceneStateBridge */", "/** @typedef {{ _libReady?: boolean, getSceneState: (runtimeScene: gdjs.RuntimeScene) => FoliageSceneStateBridge }} FoliageExtensionBridge */",