~firefoxsimulationsdemo
10 itemsDownload ./*

..
build
jsm
models
textures
ui
index.coins.html
index.html
index.og.html
index.twily.html
index.twilystrip.html


demoindex.twilystrip.html
125 KB• 27•  6 days ago•  DownloadRawClose
6 days ago•  27

{}
<!DOCTYPE html>
<!--
    Author: Twily                                           2025-2026
    Website: twily.info
    Description: threejs playground animation rigging skinned mesh

    // keybinds
    // WASD  Movement keys (+arrows)
    // QE    Extra movement
    // C     Crouch/Descend
    // Shift Sprint
    // Space Jump/Ascend
    // J     Toggle Joysticks (Game Mode)
    // G     Toggle Game Mode (Mouse)
    // M     Toggle Music
    // O     Ortho Camera Modes
    // I     Toggle Wireframe
    // U     Toggle Underwear

    music:  Look How High I Took You - Dark Hypnotic Techno [&&]
      Dark & Progressive House Set at the Grand Canyon - DΛRK SØUND
-->
<html lang="en">
<head>
    <meta charset="UTF-8">
    <!--<meta name="viewport" content="width=device-width, initial-scale=1.0">-->
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
    <title>Three.js FPS Demo Engine</title>
    <style>
        html,body {
            font-size: 10pt;
            font-family: "Droid Sans", "Liberation Sans", "DejaVu Sans", "Segoe UI", Sans;
            width: 100%; height: 100%;
            margin: 0; padding: 0;
            overflow: hidden;
            user-select: none;
            background: #000000; color: #b5857f;
            overscroll-behavior: none; /* Stops the pull-down glow/refresh */
            touch-action: none;        /* Prevents browser handling of gestures */
    -webkit-overflow-scrolling: auto;
        }
        canvas {
            display: block;
            position: relative; top: 0; left: 0;
            width: 100%; height: 100%;
            z-index: 1;
        }

        /** { box-sizing: border-box; }*/
        *:focus { outline: none !important; }
        /** { outline: 1px solid #f0f; }*/

        /* Firefox fix (The "Shift" & "Skyward View")
        "manual F11" context is the smoking gun. Firefox handles the UI transition for F11 differently than a programmatic Fullscreen API request. ..the browser keeps a "hit zone" at the top that causes a coordinate shift during the first frame of movement after a lock. */
        body.is-locked {
            position: fixed;
            overflow: hidden;
            width: 100vw;
            height: 100vh;
        }
        :fullscreen, :-moz-full-screen {
            padding: 0 !important;
            margin: 0 !important;
        }

        #maintable {
            position: relative; top: 0; left: 0;
            width: 100%; height: 100%;
            z-index: 1;
        }
        #mainframe {
            position: relative;
        }
        #overlay {
            display: block;
            position: absolute; top: 0; left: 0;
            width: 100%; height: 100%;
            z-index: 100;
            background: transparent;
            user-select: none;
        }
        #loading {
            position: fixed; top: 0; left: 0;
            width: 100%; height: 100%;
            background: transparent;
            text-shadow: 0 0 6px #000;
            color: #fff;
            font-size: 22pt;
            text-align: center;
            opacity: 1;
            transition: opacity 1s ease;
            z-index: 1001;
        }
        #loadtxt {
            width: 500px; text-align: center;
            display: inline-block;
        }
        .tbl { display: table; table-layout: fixed; }
        .tr { display: table-row; }
        .td { display: table-cell; vertical-align: middle;}

        .tbl.full { width: 100%; height: 100%; }

        #keysWrap {
            font-size: 12pt;
            font-family: "Droid Sans", "Liberation Sans", "DejaVu Sans", "Segoe UI", Sans;
            position: absolute; top: 0; left: 0;
            width: 100%; height: 100%;
            pointer-events: none;
            display: /*flex*/ none;
            justify-content: center; align-items: center;
            /*visibility: hidden;*/
            z-index: 1002;
        }
        #keys {
            display: inline-block;
            /*width: 400px; height: 400px;*/
            width: auto; min-width: 200px; height: auto;
            white-space: nowrap;
        }
        .pad18 {
            padding: 18px;
            background: rgba(11,11,13,.5);
            box-shadow: 0 0 10px 0 #111113;
            border-radius: 16px;
            display: inline-block;
        }
        .grey { color: /*#686868*/ #aaa; }
        .yellow { color: #fad900; }
        .pink { color: #fa00d9; }
        #keys > .tbl > .tr > .td:nth-child(1) {
            font-weight: bold;
        }
        .keypad {
            padding: 4px 8px;
            border: 2px solid #b5857f;
            border-radius: 10px;
            display: inline-block;
        }
        
        #leftJoyContainer {
            background: rgba(255,255,255,.1);
            width: 100px; height: 100px;
            border-radius: 200px;
            position: absolute; bottom: 50px; left: 50px;
            z-index: 999;
            display: none;
            touch-action: none; /* Prevents the browser from stealing the touch for scrolling */
        }
        #rightJoyContainer {
            background: rgba(255,255,255,.1);
            width: 100px; height: 100px;
            border-radius: 200px;
            position: absolute; bottom: 50px; right: 50px;
            z-index: 999;
            display: none;
            touch-action: none; /* Prevents the browser from stealing the touch for scrolling */
        }
        #leftJoyStick {
            background: rgba(255,255,255,.3);
            width: 50px; height: 50px;
            border-radius: 100px;
            position: absolute; top: 50%; left: 50%;
            margin-left: -25px; margin-top: -25px;
        }
        #rightJoyStick {
            background: rgba(255,255,255,.3);
            width: 50px; height: 50px;
            border-radius: 100px;
            position: absolute; top: 50%; left: 50%;
            margin-left: -25px; margin-top: -25px;
        }
    </style>
</head>
<body>
    <div id="loading"><div class="tbl full"><div class="tr"><div class="td"><span id="loadtxt">Loading...</span></div></div></div></div>
    <!--<div id="notices"></div>
    <div id="info"></div>
    <div id="keysWrap">
        <div class="pad18">
            <div id="keys"></div>
        </div>
    </div>-->
    <audio id="musicplayer"> <!-- no loop to enable track swap -->
        <source src="" type="audio/mpeg" />
        Your browser does not support the audio element.
    </audio>
    
    <div class="tbl" id="maintable">
    <div class="tr">
    <div class="td" style="height: 100%;">
        <div id="mainframe">
            <div id="overlay"></div>
        </div>
    </div>
    </div>
    </div>

    
    <div id="leftJoyContainer">
        <div id="leftJoyStick"></div>
    </div>
    <div id="rightJoyContainer">
        <div id="rightJoyStick"></div>
    </div>

    <script type="importmap">
        {
            "imports": {
                "three": "./build/three.module.js",
                "three/addons/": "./jsm/"
            }
        }
    </script>

    <script type="module">
        import * as THREE from 'three';
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
        import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';

        const $=function(id) { return document.getElementById(id); }
        String.prototype.replaceAt=function(index,replacement) { return this.substr(0,index)+replacement+this.substr(index+replacement.length); }
        const rndMinMax=function(min,max) { return Math.floor(Math.random()*(max-min+1)+min); }
        var IsJsonString=function(str) { try { JSON.parse(str); } catch(e) { return false; } return true; }

        function b64Enc(str) {
            return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,function(match,p1) {
                return String.fromCharCode(parseInt(p1,16));
            }));
        }
        function b64Dec(str) {
            try {
                return decodeURIComponent(Array.prototype.map.call(atob(str),function(c) {
                    return '%'+('00'+c.charCodeAt(0).toString(16)).slice(-2);
                }).join(''));
            } catch(e) {
                return str;
            }
        }
        var months=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
        var days=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];


// Twily is Standard but with animated geometry (skinned mesh)
// share fragment standard
const vertexShaderTwily = `
    #include <common>
    #include <uv_pars_vertex>              // Added: declares attribute vec2 uv;
    #include <displacementmap_pars_vertex>
    #include <envmap_pars_vertex>
    #include <color_pars_vertex>
    #include <fog_pars_vertex>
    #include <lights_pars_begin>
    #include <normal_pars_vertex>           // Declares varying vec3 vNormal (view-space)
    #include <morphtarget_pars_vertex>
    #include <skinning_pars_vertex>
    #include <logdepthbuf_pars_vertex>
    #include <clipping_planes_pars_vertex>

    varying vec2 TexCoord; // vUv
    varying vec3 vWorldNormal;             // world-space normal (transformed)
    varying vec3 vWorldPos;                // world-space position
    // varying vec3 vNormal;               // Removed: redefined by <normal_pars_vertex>; it's now view-space if you need it
    varying vec3 vObjectNormal;

    // Manual shadow coord
    varying vec4 vSunShadowCoord;
    varying vec4 vClockShadowCoord;
    uniform float shadowNormalBias;
    uniform float clockShadowNormalBias;
    
    uniform mat4 sunShadowMatrix;
    uniform mat4 clockShadowMatrix;
    uniform int shadowOn;

    void main() {
        #include <color_vertex>
        #include <morphcolor_vertex>

        #include <beginnormal_vertex>
        #include <morphnormal_vertex>
        #include <skinbase_vertex>          // Computes skinMatrix from bone transforms
        #include <skinnormal_vertex>        // Applies skinMatrix to objectNormal

        #include <defaultnormal_vertex>     // Added: transforms objectNormal to view-space (transformedNormal = normalMatrix * objectNormal)
        #include <normal_vertex>            // Added: sets vNormal = normalize(transformedNormal) with flip if needed

        #include <begin_vertex>             // Sets transformed = position
        #include <morphtarget_vertex>
        #include <skinning_vertex>          // Applies skinMatrix to transformed → now deformed!

        #include <displacementmap_vertex>   // Added: if you're using displacement (since you have the pars include)
        #include <project_vertex>           // Sets mvPosition and gl_Position

        #include <logdepthbuf_vertex>
        #include <clipping_planes_vertex>

        TexCoord = uv;
        vObjectNormal = normal;

        // vNormal is now set to view-space normal via includes (if you need object-space, add a custom varying vec3 vObjectNormal = objectNormal;)

        // World-space normal (using skinned objectNormal; correct for random rotation)
        mat3 normalMat3 = mat3(transpose(inverse(modelMatrix))); // proper normal transform
        vWorldNormal = normalize(normalMat3 * objectNormal);

        vec4 worldPos = modelMatrix * vec4(transformed, 1.0); // Use transformed (skinned position)
        vWorldPos = worldPos.xyz;

        // Offset for sun
        vec3 offset = normal * shadowNormalBias; // object-space offset (assumes uniform scale; if not, use worldNormal below)
        vec3 clockOffset = normal * clockShadowNormalBias; // object-space offset (assumes uniform scale; if not, use worldNormal below)
        // For non-uniform scale: vec3 offset = vWorldNormal * shadowNormalBias;
        vec4 offsetWorldPos = modelMatrix * vec4(position + offset, 1.0);
        vec4 clockOffsetWorldPos = modelMatrix * vec4(position + clockOffset, 1.0);
        
        if(shadowOn >= 1) {
          vSunShadowCoord = sunShadowMatrix * offsetWorldPos;
          // Repeat for moon with another offsetWorldPos if separate bias, but same for now
        }
        vClockShadowCoord = clockShadowMatrix * clockOffsetWorldPos; // sundial always on

        vFogDepth = -mvPosition.z;    
    }
`;

const vertexShaderStandard = `
    varying vec2 TexCoord; // vUv
    varying vec3 vWorldNormal;      // world-space normal (transformed)
    varying vec3 vWorldPos;         // world-space position
    varying vec3 vObjectNormal;         // world-space position
    //varying float vFogDepth;

    // Manual shadow coord
    varying vec4 vSunShadowCoord;
    varying vec4 vClockShadowCoord;
    uniform float shadowNormalBias;
    uniform float clockShadowNormalBias;
    
    uniform mat4 sunShadowMatrix;  // from uniform
    uniform mat4 clockShadowMatrix;
    uniform int shadowOn;

    ${THREE.ShaderChunk['common']}
    ${THREE.ShaderChunk['fog_pars_vertex']}

    void main() {
        TexCoord = uv;
        vObjectNormal = normal;
        
        // World-space normal (correct for random rotation)
        mat3 normalMat3 = mat3(transpose(inverse(modelMatrix))); // proper normal transform
        vWorldNormal = normalize(normalMat3 * normal);

        vec4 worldPos = modelMatrix * vec4(position, 1.0);
        vWorldPos = worldPos.xyz;

        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);

        vFogDepth = -mvPosition.z;

        // Offset for sun
        vec3 offset = normal * shadowNormalBias; // object-space offset (assumes uniform scale; if not, use worldNormal below)
        vec3 clockOffset = normal * clockShadowNormalBias; // object-space offset (assumes uniform scale; if not, use worldNormal below)
        // For non-uniform scale: vec3 offset = vWorldNormal * shadowNormalBias;
        vec4 offsetWorldPos = modelMatrix * vec4(position + offset, 1.0);
        vec4 clockOffsetWorldPos = modelMatrix * vec4(position + clockOffset, 1.0);
        
        if(shadowOn >= 1) {
          vSunShadowCoord = sunShadowMatrix * offsetWorldPos;
          // Repeat for moon with another offsetWorldPos if separate bias, but same for now
        }
        vClockShadowCoord = clockShadowMatrix * clockOffsetWorldPos; // sundial always on

        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        
        ${THREE.ShaderChunk['fog_vertex']}
    }
`;
const fragmentShaderStandard = `
   precision highp float;

    ${THREE.ShaderChunk['common']}
    ${THREE.ShaderChunk['packing']}
    ${THREE.ShaderChunk['fog_pars_fragment']}

    uniform sampler2D map;
    uniform sampler2D normalMap;
    uniform sampler2D roughnessMap;
    uniform sampler2D emissiveMap;
    uniform vec3 emissive; // color
    uniform float emissiveMulti;
    uniform int emissiveShadow;
    uniform vec3 lightDir;
    uniform vec3 lightDir2;
    uniform float daytime; // 1 0 1
    //uniform float xtime;
    uniform float repeatScaleX;
    uniform float repeatScaleY;

    uniform int shadowOn;
    uniform float alphaThreshold;
    uniform float roughness;
    uniform float metallic;
    uniform int transparent; // for clip or alpha
    uniform int metarough; // 0(none/values) 1(rough r) or 2(metarough bg)
    uniform int flipNormal; // 0 1(x) 2(y) or 3(xy)
    uniform int flatFace;

    uniform float rimShineStrength;
    uniform vec3 rimShineColor;

    varying vec2 TexCoord; // vUv
    varying vec3 vWorldNormal;
    varying vec3 vObjectNormal; // old vNormal but vNormal from twily include skinned mesh is view-space
    varying vec3 vWorldPos;
    //varying float vFogDepth;

    uniform float ambientMulti;
    uniform float normalStrength;

    void main() {
        vec2 vUv = vec2(TexCoord.x * repeatScaleX,TexCoord.y * repeatScaleY); // Scale UVs in vertex for repeating

        vec4 albedo = texture(map, vUv);

        float rough=roughness; // 0.5
        float metal=metallic; // 0.0
        if(metarough==1) {
            rough=texture2D(roughnessMap, vUv).r; // or b/w?
        } else if(metarough==2) {
            rough=texture2D(roughnessMap, vUv).g;
            metal = texture2D(roughnessMap, vUv).b;
        }

        vec4 emissiveColor = texture2D(emissiveMap, vUv);
        vec3 totalEmissiveRadiance = (emissiveColor.rgb * emissive * emissiveMulti);
        //vec3 totalEmissiveRadiance = (emissiveColor.rgb * emissive * emissiveMulti) * (((emissiveColor.r+emissiveColor.g+emissiveColor.b)*.333));

        // Object-space normal map perturbation
        vec3 normalTex = texture(normalMap, vUv).rgb * 2.0 - 1.0;

        // 2. Flip the channels you need
        if(flipNormal==1 || flipNormal==3) {
            normalTex.x *= -1.0; // Flip horizontal (Red)
        }
        if(flipNormal==2 || flipNormal==3) {
            normalTex.y *= -1.0; // Flip vertical (Green) - MOST COMMON FIX
        }
        // google ai solved my final dual shadow issues https://share.google/aimode/OfmzmnqFXiKydjrBE

        vec3 normal = vWorldNormal;
        if(flatFace==1) {
            normal = vWorldNormal * (gl_FrontFacing ? 1.0 : -1.0);
        }

        // Add bump to world-space normal
        //vec3 finalNormal = normalize(vWorldNormal + normalTex * normalStrength); // strength 0.8, tune as needed
        vec3 finalNormal = normalize(normal + normalTex * normalStrength); // strength 0.8, tune as needed

        vec3 viewDir = normalize(cameraPosition - vWorldPos);

        float ao=1.0;
        
        float zeron=smoothstep(0.0,0.2,abs((daytime*2.0)-1.0)); // 1 0 1
        float mixn=(1.0-((daytime*0.5)+0.5))*zeron; // 0 - 0.5 // night half
        float mixt=((daytime*0.5)+0.5)*zeron; // 0.5 - 1 // day half

        vec3 lightDir2Mod=vec3(-lightDir2.x,-lightDir2.y,-lightDir2.z);

        // Diffuse (sun + moon)
        float diff     = max(dot(lightDir,   finalNormal), 0.0) * ao * mixt;
        float diffMoon = max(dot(lightDir2Mod, finalNormal), 0.0) * ao * mixn;

        // ──────────────────────────────────────────────────────
        // STANDARD SPECULAR (BLINN-PHONG)
        // ──────────────────────────────────────────────────────
        vec3 halfway     = normalize(lightDir   + viewDir);
        vec3 halfwayMoon = normalize(lightDir2Mod + viewDir);
        
        // Base specular highlights
        float specDayBase  = pow(max(dot(finalNormal, halfway),     0.0), 64.0); // Higher power for tighter glare glint
        float specMoonBase = pow(max(dot(finalNormal, halfwayMoon), 0.0), 32.0);
        
        float spec     = specDayBase  * rough * (0.04 + metal) * mixt;
        float specMoon = specMoonBase * rough * (0.04 + metal) * mixn;

        // ──────────────────────────────────────────────────────
        // DIRECTIONAL FRESNEL RIM (BACKLIGHT/RIM GLOW)
        // ──────────────────────────────────────────────────────
        float NdotV_geo   = max(0.0, dot(normalize(vObjectNormal), viewDir));
        float NdotV_bump  = max(0.0, dot(finalNormal,            viewDir));
        float NdotV       = mix(NdotV_geo, NdotV_bump, 0.6);

        // Base edge ring intensity
        float fresnel     = pow(max(1.0 - NdotV, 0.0), 3.0);

        // Alignment vectors for directional backlighting
        // We look for where the view directory aligns with the outgoing light path
        float rimAlignmentDay   = max(dot(-lightDir, viewDir), 0.0);
        float rimAlignmentNight = max(dot(-lightDir2Mod, viewDir), 0.0);

        // Sharpen the alignment window so it concentrates specifically on back edges
        float directionalMaskDay   = pow(rimAlignmentDay, 2.0);
        float directionalMaskNight = pow(rimAlignmentNight, 2.0);

        // Assemble directional weights linked directly to active environmental time blocks
        float dynamicDirectionalRim = (directionalMaskDay * mixt) + (directionalMaskNight * mixn);

        // Environmental multipliers
        float rimRoughnessMod  = mix(0.6, 1.4, rough);
        float rimStrengthNight = mix(0.5, 1.8, mixn);

        // Final combined rim value masked by the directional vectors
        float rimShine = fresnel * rimShineStrength * rimRoughnessMod * rimStrengthNight * dynamicDirectionalRim;

        // ──────────────────────────────────────────────────────
        // NEW: FAKE METALLIC SUN/MOON GLARE (SPECULAR BOOST)
        // ──────────────────────────────────────────────────────
        // Compute the reflection vector of the sun/moon light off the face normal
        vec3 reflectDirDay  = reflect(-lightDir, finalNormal);
        vec3 reflectDirMoon = reflect(-lightDir2Mod, finalNormal);
        //reflectDirDay.z=reflectDirDay.z;
        reflectDirMoon.z=-reflectDirMoon.z;

        // Check how closely the camera view vector lines up with the reflection angle
        float glareFactorDay  = max(dot(reflectDirDay, viewDir), 0.0) * 1.02;
        float glareFactorMoon = max(dot(reflectDirMoon, viewDir), 0.0) * 1.0;

        // Sharpen the reflection heavily so it only catches at the perfect viewing angle
        // True metals have intensely concentrated reflection highlights
        float metallicGlareDay  = pow(glareFactorDay, 128.0) * metal * (1.0 - rough) * mixt * 5.5;
        float metallicGlareMoon = pow(glareFactorMoon, 64.0) * metal * (1.0 - rough) * mixn * 1.5;

        // Metallic reflection rule: Gold/Bronze metals tint their reflections with their own color
        vec3 sunGlareColor  = mix(vec3(1.0, 0.95, 0.85), albedo.rgb, metal) * metallicGlareDay;
        vec3 moonGlareColor = mix(vec3(0.7, 0.85, 1.0), albedo.rgb, metal) * metallicGlareMoon;
        vec3 totalMetallicGlare = sunGlareColor + moonGlareColor;

        // ──────────────────────────────────────────────────────
        // COMPOSITING THE FINAL PASS (WITH BALANCED SUNLIGHT INTENSITY)
        // ──────────────────────────────────────────────────────
        // Boosted base diffuse multiplier slightly to counter the early morning sun angle dimness
        vec3 diffuseComposition = albedo.rgb * max(diff + diffMoon, 0.25) * 4.2;
        vec3 standardSpecular   = vec3(spec + specMoon);
        vec3 directionalRim     = rimShineColor * rimShine;

        // Add the new glare step straight on top of the composition matrix
        vec3 color = diffuseComposition + standardSpecular + directionalRim + totalMetallicGlare;

        // shadow section stripped

        #ifdef USE_ALBEDO_MAP
            float alpha = albedo.a;
        #else
            float alpha = 1.0;
        #endif

        if (alpha < alphaThreshold && transparent == 0) discard; // Clip transparent pixels (no blending, but depth sorting works)

        // Fog application (using your vFogDepth)
        float fogFactor = 1.0 - exp(-fogDensity * fogDensity * vFogDepth * vFogDepth);
        color *= max(fogColor,.2) * 2.0;
        color = mix(color, fogColor, fogFactor);

        //if(emissiveShadow==1) {
        //    totalEmissiveRadiance*=(shadow + 1.0);
        //}

        //#include <fog_fragment>
        color+=totalEmissiveRadiance;

        //if(shadowOn>=2) {
        //    gl_FragColor = vec4(vec3(shadow , 0.0, 1.0-shadow), alpha);
        //} else {
            gl_FragColor = vec4(color, alpha);
        //}
        //gl_FragColor = vec4(rough, rough, rough, 1.0);
        //gl_FragColor = vec4(finalNormal, 1.0);
        //gl_FragColor = vec4(halfway, 1.0);
        //gl_FragColor = vec4(shadow,shadow,shadow, 1.0);
        //gl_FragColor = vec4(normalTex, 1.0);
        //gl_FragColor = vec4(vWorldNormal, 1.0);

        //gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // red debug
    }
`;


// additional to be added
// tree shader for vegetation and simple 2d plane alpha clipping geometry

// fireworks shader for animated sprite images and opacity fade

//  end of custom shaders
// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

        // linear(k): Returns k unchanged (constant speed)
        function linear(k) {
          return k;
        }
        
        // easeIn(k): Starts slow and speeds up (e.g., using a power function).
        function easeIn(k) {
          return Math.pow(k, 2); // Quadratic ease-in
        }
        
        // easeOut(k): Starts fast and slows down (a common natural effect, similar to the continuous lerp approach).
        function easeOut(k) {
          return 1 - Math.pow(1 - k, 2); // Quadratic ease-out
        }
        
        // easeInOut(k): Starts slow, speeds up in the middle, and slows down at the end.
        function easeInOut(k) {
          return k < 0.5 ? 2 * Math.pow(k, 2) : 1 - Math.pow(1 - k, 2) * 2; // Quadratic ease-in-out
          // A common alternative using Math.sin for a smoother curve:
          // return 0.5 * (Math.sin((k - 0.5) * Math.PI) + 1);
        }

        // standard lerp t=0-1
        function lerp(start,end,t) {
            return start * (1 - t) + end * t;
        }

        // To implement a smoothstep version of your custom lerp in C# for 2026, you can use the standard cubic Hermite interpolation formula. This function creates an "S-curve" that starts and ends slowly, making transitions feel more natural
        function SmoothStep(start,end,t) {
            // Clamp t between 0 and 1 to prevent overshoot
            if(t>1.0) t=1.0;
            else if(t<0.0) t=0.0;

            // The smoothstep cubic formula: 3t^2 - 2t^3
            t = t * t * (3.0 - 2.0 * t);

            // Standard interpolation using the smoothed 't'
            //return start * (1f - t) + end * t;
            return lerp(start,end,t);
        }
        
        const radian=Math.PI / 180.0;
        
        let helperGrids = false; // debug
        let helperRef=[null,null];
        function grid_toggle() {
            helperGrids=!helperGrids;
            if(helperGrids) {
                const helper = new THREE.GridHelper(500, 500);
                helper.material.opacity = 0.25;
                helper.material.transparent = true;
                scene.add(helper);
                const axis = new THREE.AxesHelper(1000);
                scene.add(axis);
                helperRef[0]=helper;
                helperRef[1]=axis;
            } else {
                helperRef[0].material.dispose();
                scene.remove(helperRef[0]);
                scene.remove(helperRef[1]);
            }
        }

        function updateLookAt(animData, playerGroup, delta, maxAngleDeg = 55) {
            // If weight hits absolute 0, immediately bypass everything and let the mixer win
            if (animData.lookWeight <= 0) return;
 
            const bone = animData.neckbone;
            if (!bone) return;
        
            // 1. Get current world positions
            const bonePos = new THREE.Vector3();
            bone.getWorldPosition(bonePos);
        
            const targetPos = new THREE.Vector3();
            playerGroup.getWorldPosition(targetPos);
            //targetPos.y=1; // look height
            targetPos.y-=0.4;
        
            // 2. Determine target vector and check field of view limits
            const toTarget = new THREE.Vector3().subVectors(targetPos, bonePos).normalize();
        
            const characterForward = new THREE.Vector3(0, 0, 1);
            if (bone.parent) {
                bone.parent.getWorldDirection(characterForward);
            }
        
            const angleToTarget = characterForward.angleTo(toTarget) * THREE.MathUtils.RAD2DEG;
            const inView = angleToTarget <= maxAngleDeg;
        
            // 3. Define the desired orientation target
            const desiredQuat = new THREE.Quaternion();
        
            if (inView) {
                // Build the look-at world matrix target
                const lookMatrix = new THREE.Matrix4().lookAt(bonePos, targetPos, new THREE.Vector3(0, 1, 0));
                const worldTargetQuat = new THREE.Quaternion().setFromRotationMatrix(lookMatrix);
        
                // Your custom rig offset that perfectly centers the face forward
                const rigOffset = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), ((Math.PI * 0.5) * 2.5)); // 90 deg * 2.5
                worldTargetQuat.multiply(rigOffset);
        
                // Transform the corrected world rotation into the bone's local coordinate space
                if (bone.parent) {
                    const parentWorldQuat = new THREE.Quaternion();
                    bone.parent.getWorldQuaternion(parentWorldQuat);
                    desiredQuat.copy(parentWorldQuat).invert().multiply(worldTargetQuat);
                } else {
                    desiredQuat.copy(worldTargetQuat);
                }
                //if(angleToTarget<=75) { // max head turn but hold until maxangledeg
                    // Smoothly blend the tracking target state towards the player's active position
                    animData.targetLookQuat.slerpQuaternions(
                        animData.currentLookQuat,   // from: previous look frame
                        desiredQuat,                // to: new look coordinates
                        Math.min(12 * delta, 1)     // t: blend speed
                    );
                    animData.currentLookQuat.copy(animData.targetLookQuat);
                //}
        
            } else {
                // BEYOND THE LOOK ARC:
                // Instantly make the goalpost target the clean, raw animation track
                desiredQuat.copy(animData.baseQuat);
                
                // Smoothly slide the tracking target back toward center forward
                animData.targetLookQuat.slerpQuaternions(
                    animData.currentLookQuat,
                    desiredQuat,
                    Math.min(12 * delta, 1)
                );
                animData.currentLookQuat.copy(animData.targetLookQuat);
            }
        
            // 4. CRITICAL BLENDING REWRITE:
            // Forcefully wipe the bone clean with the live animation frame first.
            bone.quaternion.copy(animData.baseQuat);
            
            // Standard slerp cleanly stacks the weight calculation over the top of the 
            // live base frame, scaling the overall strength exactly as lookWeight changes.
            bone.quaternion.slerp(animData.targetLookQuat, animData.lookWeight);
        }
       
        let instanceIdx=0;
        function instance_twily() {
            const twily=refList['twily'];
            twily.updateMatrixWorld(true);
            // Import SkeletonUtils if using modules, or use THREE.SkeletonUtils

            const rows=20;
            const cells=5;
            //const rows=100;
            //const cells=1;
            //const rows=100;
            //const cells=10;
            const gap=4.0;
            const offsetX=rows*gap;
            const offsetZ=cells*gap;

            for(let x=0;x<rows;x++) {
                for(let z=0;z<cells;z++) {
                    let oX=(x*gap)-(offsetX*.5);
                    let oZ=(z*gap)-offsetZ;

                    const twilyInstance = SkeletonUtils.clone(twily); 
                    //const twilyInstance = twily.clone();
                    const instanceAnim = {
                        twily: null,
                        mixer: null,
                        actions: {},
                        current: null,
                        nextAnim: 0.0,
                        returnAnim: null,
                        nextBlink: 0.0,
                        blinkDelta: 0.0,
                        shapemesh: null,
                        neckbone: null,
                        baseQuat: new THREE.Quaternion(),
                        nextLook: 0.0,
                        lookTarget: new THREE.Vector3(),
                        lookWeight: 0.0,
                        isLooking: false,
                        currentLookQuat: new THREE.Quaternion(),
                        targetLookQuat: new THREE.Quaternion(),
                    };
                    instanceAnim.twily=twilyInstance;

                    if (twilyAnimations && twilyAnimations.length > 0) {
                        instanceAnim.mixer = new THREE.AnimationMixer(twilyInstance);

                        function createAction(clipName, loopMode = THREE.LoopRepeat, clamp = false, weight = 1.0) {
                            const clip = THREE.AnimationClip.findByName(twilyAnimations, clipName);
                            if (!clip) {
                                console.warn(`Animation clip "${clipName}" not found!`);
                                return null;
                            }
                            const action = instanceAnim.mixer.clipAction(clip);
                            action.setLoop(loopMode);
                            action.clampWhenFinished = clamp;
                            action.weight = weight;
                            return action;
                        }

                        // Assign them – adjust exact names based on your console output!
                        // Common Blender-exported names: "Idle", "Start", "Cycle", "End", or sometimes "Armature|Idle", "rig.Cycle"
                        const idleAction        = createAction("AnimIdle",   THREE.LoopRepeat, false);
                        const idleLookAction = createAction("AnimIdleLook",  THREE.LoopRepeat, false);
                        const idlePonyAction = createAction("AnimPonyIdleLoop",  THREE.LoopRepeat, false);
                        const idleToPonyAction = createAction("AnimIdleToPony",  THREE.LoopOnce, true);
                        const ponyToIdleAction = createAction("AnimPonyToIdle",  THREE.LoopOnce, true);
                        const squatsAction = createAction("AnimSquats",  THREE.LoopRepeat, false);
                        //const idleLookAction = createAction("AnimIdleLook",  THREE.LoopOnce,   true);   // one-shot, freeze at end

                        // Store for easy reference
                        instanceAnim.actions.idle        = idleAction;
                        instanceAnim.actions.idlelook    = idleLookAction;
                        instanceAnim.actions.idlepony    = idlePonyAction;
                        instanceAnim.actions.idletopony  = idleToPonyAction;
                        instanceAnim.actions.ponytoidle  = ponyToIdleAction;
                        instanceAnim.actions.squats  = squatsAction;

                        // Start with Idle
                        if (idleAction) {
                            idleAction.play();
                            instanceAnim.current = idleAction;
                            //console.log("Started Idle animation instance");
                        }
                    
                        refList['twilyInstance'].push(instanceAnim);
                        // instanceIdx matches push count here
                    } else {
                        console.warn("twily.gltf has no animations");
                    }

                    twilyInstance.position.set(oX,0.0,oZ);
                    //twilyInstance.scale.setScalar(100);
                    //console.dir(twilyInstance);

                    twilyInstance.traverse(child => {
                        child.frustumCulled = false;         // optional safety — prevents culling bugs on animated bounds
                        // Optional: make bounds more forgiving
                        if (child.geometry?.boundingSphere) {
                            child.geometry.boundingSphere.radius *= 2.0;
                        }
                        if (child.isMesh && child.material) {
                            child.material = refList['twilyMaterial'];
                        
                            //console.log("Applied custom twily material to instance:", child.name);
                        }
                        if (child.isSkinnedMesh) {
                            if(child.morphTargetInfluences) {
                                //shapemesh=child;
                                instanceAnim.shapemesh=child;
                            }
                        }
                        if (child.isBone && child.name === 'neck') {
                            instanceAnim.neckbone = child;

                            //instanceAnim.baseQuat.copy(child.quaternion)

                        }
                    });

                    if(!initialized) {
                        twilyInstance.visible=false;
                    }

                    scene.add(twilyInstance);

                    //console.log("twilyInstance"+instanceIdx+", oX: "+oX+", oZ: "+oZ);
                    instanceIdx++;
                }
            }
        }

        const blinkDuration = 1.0;
        const blinkSpeed = 5.0; // adjust this instead, 5.0 speed of 1.0 is .2 duration
        const twilyAnim = {
            mixer: null,
            current: null,
            actions: {},
            nextAnim: 0.0,
            returnAnim: null,
            nextBlink: 0.0,
            blinkDelta: 0.0,
            shapemesh: null,
            neckbone: null,
            baseQuat: new THREE.Quaternion(),
            nextLook: 0.0,
            lookTarget: new THREE.Vector3(),
            lookWeight: 0.0,
            isLooking: false,
            currentLookQuat: new THREE.Quaternion(),
            targetLookQuat: new THREE.Quaternion(),
        };
        let twilyAnimations = null;

        function preload_twily() {
            const textureLoader = new THREE.TextureLoader(manager);
            const twilyNude = textureLoader.load('./models/twilysquat/finaltwily.webp?v=3.7');
            twilyNude.colorSpace = THREE.SRGBColorSpace;
            twilyNude.flipY = false;
            [twilyNude].forEach(tex => {
                tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
                tex.repeat.set(1, 1); // Adjust based on your scene scale
            });
            refList['twilyTextureNude']=twilyNude;
                            
            load_twily();
        }

        let twily = null;
        function load_twily() {
            const loader = new GLTFLoader();
            loader.load('./models/twilysquat/twilysquat.gltf?v=3.7', (gltf) => {
                console.log("GLTF loaded successfully!");
                console.log("Scene children:", gltf.scene.children.length);
                console.log("Animations:", gltf.animations?.length || 0);
                gltf.animations?.forEach(clip => {
                    console.log("Clip:", clip.name, "duration:", clip.duration);
                });

                twily = gltf.scene;
                twilyAnimations = gltf.animations || [];

                twily.castShadow = true; // prep for shadows later
                twily.receiveShadow = true;

                //twily.scale.set(1.0, 1.0, 1.0);
                //twily.scale.setScalar(1.6*10.0);
                twily.scale.setScalar(1.6);
                //const box = new THREE.Box3().setFromObject(twily);
                //console.log("twily bounding box:", box.min, box.max);
                //console.log("Size:", box.getSize(new THREE.Vector3()));

                //twily.position.clone(camera.position);
                //twily.position.set(-22, 53, -170);

                //twily.position.set(0, 53.43, 2); // front head on
                twily.position.set(2.0, 0.0, 0.0); // side

                twily.updateMatrixWorld(true);

                console.log("twily loaded");

                twily.renderOrder = 100; // High renderOrder to draw above everything in its pass

                twily.traverse(child => {
                    if (child.isMesh && child.material) {
                        if(refList['twilyMaterial']==null) {
                            child.castShadow = true; // twily doesn't cast on main map (only self-shadows on clock map)
                            child.receiveShadow = true; // Still receives

                            child.scale.setScalar(1);   // force initial scale to 1 so baked tracks start from visible size
                            console.log(child.name+" - "+child.material.name);

                            let textureMap=refList['twilyTextureNude'];
                            if(!nudeOn) {
                                textureMap=child.material.emissiveMap;
                            }
                            //child.material = createCustomtwilyMaterial(child.material); // or use single instead
                            //console.dir(child.material);

                            // Create custom mat here for FRESH material per mesh (don't reuse shared twilyMaterial) 
                            const customMat = new THREE.ShaderMaterial({
                                uniforms: THREE.UniformsUtils.clone( // clone to avoid sharing
                                    THREE.UniformsUtils.merge([
                                        THREE.UniformsLib.lights,
                                        THREE.UniformsLib.fog,
                                        {
                                            //map: { value: child.material.map }, // per-child texture
                                            //normalMap: { value: child.material.normalMap },
                                            //roughnessMap: { value: child.material.roughnessMap },
                                            //map: { value: child.material.emissiveMap },
                                            map: { value: null },
                                            normalMap: { value: null },
                                            roughnessMap: { value: null },
                                            emissiveMap: { value: textureMap },
                                            emissive: { value: new THREE.Color(0xFFFFFF) },
                                            emissiveMulti: { value: 1.5 },
                                            emissiveShadow: { value: 1 },
                                            repeatScaleX: { value: 1.0 },
                                            repeatScaleY: { value: 1.0 },
                                            flipNormal: { value: 0 }, // 0 1(x) 2(y) or 3(xy)
                                            flatFace: { value: 1 },
                                            lightDir: { value: refList['sunLight'].position.normalize() },
                                            lightDir2: { value: refList['sunLight'].position.normalize().negate() },
                                            daytime: { value: 0.0 },
                                            cameraPosition: { value: refList['camera'].position.clone() },
                                            fogColor: { value: new THREE.Color(0xffeeff) },
                                            fogDensity: { value: fogDensity },
                                            alphaThreshold: { value: 0.5 }, // clip
                                            transparent: { value: 1 }, // clip or alpha
                                            metarough: { value: 0 }, // 0, 1 or 2
                                            roughness: { value: 0.5 }, // default val 0.5
                                            metallic: { value: 0.0 }, // default val 0.0
                                            rimShineStrength: { value: 0.28 },
                                            rimShineColor: { value: new THREE.Color(1.0, 0.98, 0.92) },
                                            ambientMulti: { value: 1.0 },

                                            normalStrength: { value: 0.0 },
                                        }
                                    ])
                                ),
                                //vertexShader: vertexShaderStandard,
                                vertexShader: vertexShaderTwily,
                                fragmentShader: fragmentShaderStandard,
                                side: THREE.DoubleSide, // safer for joined models and shadow precision~ (keep)
                                fog: true,
                                lights: true,
                                transparent: false, // or true if you need alpha
                                //skinning: true,
                                //morphTargets: false,
                                depthWrite: true,
                                depthTest: true,
                            });
                
                            //refList['twilyMaterial'].push(customMat);
                            //console.dir(customMat);
                            refList['twilyTextureUnderwear']=child.material.emissiveMap;

                            child.material = customMat;
                            refList['twilyMaterial']=customMat;
                        } else {
                            child.material = refList['twilyMaterial'];
                        }
                        console.log("Applied custom twily material to:", child.name);
                    }
                    //child.layers.set(5);

                    //if (child.isMesh) child.geometry.computeBoundingBox();
                });

                //twily.layers.set(5);

                scene.add(twily);

                if(!initialized) {
                    twily.visible=false;
                }
                refList['twily']=twily;

                let shapemesh=null;

                if (gltf.animations && gltf.animations.length > 0) {
                    twilyAnim.mixer = new THREE.AnimationMixer(twily);

                    function createAction(clipName, loopMode = THREE.LoopRepeat, clamp = false, weight = 1.0) {
                        const clip = THREE.AnimationClip.findByName(gltf.animations, clipName);
                        if (!clip) {
                            console.warn(`Animation clip "${clipName}" not found!`);
                            return null;
                        }
                        const action = twilyAnim.mixer.clipAction(clip);
                        action.setLoop(loopMode);
                        action.clampWhenFinished = clamp;
                        action.weight = weight;
                        return action;
                    }

                    // Assign them – adjust exact names based on your console output!
                    // Common Blender-exported names: "Idle", "Start", "Cycle", "End", or sometimes "Armature|Idle", "rig.Cycle"
                    const idleAction        = createAction("AnimIdle",   THREE.LoopRepeat, false);
                    const idleLookAction = createAction("AnimIdleLook",  THREE.LoopRepeat, false);
                    const idlePonyAction = createAction("AnimPonyIdleLoop",  THREE.LoopRepeat, false);
                    const idleToPonyAction = createAction("AnimIdleToPony",  THREE.LoopOnce, true);
                    const ponyToIdleAction = createAction("AnimPonyToIdle",  THREE.LoopOnce, true);
                    const squatsAction = createAction("AnimSquats",  THREE.LoopRepeat, false);
                    //const idleLookAction = createAction("AnimIdleLook",  THREE.LoopOnce,   true);   // one-shot, freeze at end

                    // Store for easy reference
                    twilyAnim.actions.idle        = idleAction;
                    twilyAnim.actions.idlelook    = idleLookAction;
                    twilyAnim.actions.idlepony    = idlePonyAction;
                    twilyAnim.actions.idletopony  = idleToPonyAction;
                    twilyAnim.actions.ponytoidle  = ponyToIdleAction;
                    twilyAnim.actions.squats  = squatsAction;

                    // Start with Idle
                    if (idleAction) {
                        idleAction.play();
                        twilyAnim.current = idleAction;
                        console.log("Started Idle animation");
                    }
                } else {
                    console.warn("twily.gltf has no animations");
                }

                twily.traverse(child => {
                    if (child.isSkinnedMesh) {
                        child.frustumCulled = false;         // optional safety — prevents culling bugs on animated bounds
                        // Optional: make bounds more forgiving
                        child.geometry.boundingSphere.radius *= 2.0;

                        if(child.morphTargetInfluences) {
                            //shapemesh=child;
                            twilyAnim.shapemesh=child;
                        }
                    }
                    //if (child.isBone && (child.name === 'neck' || child.name === 'Neck' || child.name === 'Head')) {
                    if (child.isBone && child.name === 'neck') {
                        twilyAnim.neckbone = child;

                        //twilyAnim.baseQuat.copy(child.quaternion)
                    }
                });

                console.log("twily added to scene");

                //if(shapemesh!=null) {
                //    console.log("twily has shapekeys");
                    // Method 1: Modify by array index
                    //shapemesh.morphTargetInfluences[1] = 0.5; // Halfway between 
                    //shapemesh.morphTargetInfluences[2] = 0.5; // Halfway between 

                    // Method 2: Modify by name using the dictionary
                    //const idx = mesh.morphTargetDictionary['MyShapeKeyName'];
                    //if (idx !== undefined) {
                    //  twily.morphTargetInfluences[idx] = 1.0; // Full influence
                    //}
                //}


                setTimeout(function() { instance_twily(); },50);


                initialized = true;
                hideLoadScreen();

                setTimeout(function() {
                    if(refList['twily']!=null) {
                        refList['twily'].visible = true;
                        for(let i=0;i<instanceIdx;i++) {
                            refList['twilyInstance'][i].twily.visible = true;
                        }
                    }
                });
            });
        }

        // transition helper functions
       // Helper: crossfade from current → target action (with reset if one-shot)
        function crossFadeTo(targetAction, fadeDuration = 0.6, warp = true, iidx=-1) {
            const targetAnim=(iidx>-1)?refList['twilyInstance'][iidx]:twilyAnim;

            if (!targetAnim.current || !targetAction || targetAnim.current === targetAction) return;

            if (targetAction.loop === THREE.LoopOnce || targetAction.loop === THREE.LoopPingPong) {
                targetAction.reset();  // rewind to frame 0 + reset weight
            }

            targetAction
                .reset()
                .setEffectiveTimeScale(1.0)
                .setEffectiveWeight(1.0)
                .fadeIn(fadeDuration)
                .play();

            targetAnim.current.crossFadeTo(targetAction, fadeDuration, warp, iidx);

            targetAnim.current = targetAction;
        }

        // Assumes all related actions are in twilyActions with consistent naming
        function playSequence(startAction, loopAction, endAction, returnAction = null, fadeIn = 0.6, fadeOut = 0.8, iidx=-1) {
            const targetAnim=(iidx>-1)?refList['twilyInstance'][iidx]:twilyAnim;

            const start  = startAction;
            const cycle  = loopAction;   // or cycle, loop, etc.
            const end    = endAction;

            if(returnAction==null) returnAction = targetAnim.actions.idle;

            if (!start || !cycle || !end) {
                console.warn(`Incomplete sequence for animations~to early?`);
                return;
            }

            crossFadeTo(start, fadeIn, true, iidx);

            const onStartDone = (e) => {
                if (e.action === start) {
                    targetAnim.mixer.removeEventListener('finished', onStartDone);
                    crossFadeTo(cycle, fadeIn, true, iidx);
                }
            };
            targetAnim.mixer.addEventListener('finished', onStartDone);

            // Return a stop function so caller can decide when to end
            return () => {
                crossFadeTo(end, fadeOut, true, iidx);
                const onEndDone = (e) => {
                    if (e.action === end) {
                        targetAnim.mixer.removeEventListener('finished', onEndDone);
                        crossFadeTo(returnAction, fadeOut, true, iidx);
                    }
                };
                targetAnim.mixer.addEventListener('finished', onEndDone);
                targetAnim.returnAnim = null;
            };
        }
        function pointAtPlayer(planeMesh, lookPos) {
            // Create a temporary vector for the target (player head at 1.7m)
            const look = new THREE.Vector3(
                lookPos.x,
                1.7, // Your specified player height
                lookPos.z
            );
        
            planeMesh.lookAt(look);
        }
        function pointAtPlayerYOnly(planeMesh, lookPos) {
            // Set the target Y to be exactly the same as the plane's Y
            // This forces the rotation to stay vertical
            const look = new THREE.Vector3(
                lookPos.x,
                planeMesh.position.y,
                lookPos.z
            );
        
            planeMesh.lookAt(look);
        }
               
        function getDirection(theta, phi) { // dir = return
            let theta_left = theta + 90.0;
            let phi_up = phi - 90.0;
            if(theta_left>180.0) theta_left -=360.0;
            if(phi_up<-180.0) phi_up += 360.0;
           
            //const x=Math.sin(90.0 * radian) * Math.cos(theta_left * radian);
            //const y=Math.cos(90.0 * radian);
            //const z=Math.sin(90.0 * radian) * Math.sin(theta_left * radian);
            
            const x=Math.sin(phi_up * radian) * Math.cos(theta * radian);
            const y=Math.cos(phi_up * radian);
            const z=Math.sin(phi_up * radian) * Math.sin(theta * radian);
            
            //const x=Math.sin(phi * radian) * Math.cos(theta * radian);
            //const y=Math.cos(phi);
            //const z=Math.sin(phi * radian) * Math.sin(theta * radian);

            //Vec3 XZonly={ // for player full directional speed not Y
            //    cos(theta * radian),
            //    0,
            //    sin(theta * radian)
            //};
            return new THREE.Vector3(x,y,z);
        }

        // --- Core Variables ---
        let scene, clock, raycaster, mouse, mousep, mousem;

        
        const dayTint = new THREE.Vector3(1.0, 0.9, 0.8);   // Warm sun
        const nightTint = new THREE.Vector3(0.5, 0.7, 0.8); // Cool blue moon
        let currentColor = new THREE.Vector3(1.0,1.0,1.0);

        // --- Player State ---
        const player = {
            height: 1.7,
            radius: .5,
            velocity: new THREE.Vector3(),
            speed: 5.0,
            rotation: 0,     // Yaw (Left/Right)
            pitch: 0,        // Pitch (Up/Down)
            isGrounded: true,
            isCrouching: false,
            sensitivity: 0.002
        };

        const keys = {};
        
        var joy={
            left: $('leftJoyStick'), leftC: $('leftJoyContainer'),
            right: $('rightJoyStick'), rightC: $('rightJoyContainer')
        };
        var touchIndex={touch1: null, touch2: null};
        var leftJoyHold=false;
        var rightJoyHold=false;
        let ovrJoysticks = false; // for isLocked auto hide joy
        
        function refreshJoysticks() {
            if(useJoysticks && !ovrJoysticks) {
                $('leftJoyContainer').style.display="block";
                $('rightJoyContainer').style.display="block";
            } else {
                $('leftJoyContainer').style.display="none";
                $('rightJoyContainer').style.display="none";
            }
        }

        function removePinchPointer(e) {
            pinchPointers = pinchPointers.filter(p => p.pointerId !== e.pointerId);
            if (pinchPointers.length < 2) {
                prevPinchDist = -1; // Reset tracking distance
            }
        }

        let pinchPointers = [];
        let prevPinchDist = -1;

        //var touchIds = { left: null, right: null };
        // Map to track which pointer (mouse or finger) controls which side
        var activePointers = { left: null, right: null };
        // Keep track of which joystick the mouse is physically dragging
        var mouseDraggingSide = null;
        function holdJoy(e,hold=false,side="left") {
            console.log("hold joy trig side="+side);
            var register=false;
            if(hold) {
                // Store the touch ID that started this hold
                // e is the touchstart event
                //touchIds[side] = e.changedTouches[0].identifier;
                // pointerId works for both mouse (usually id 1) and multiple fingers
                // If it's a mouse (pointerType === 'mouse'), don't lock the real pointerId to both sides.
                // Instead, use a pseudo-id for the right side so it doesn't collide with the left.
                if(touchIndex['touch1']==null) {
                    touchIndex['touch1']=side;
                    register=true;
                } else if(touchIndex['touch2']==null) {
                    touchIndex['touch2']=side;
                    register=true;
                }
                if(side=="left") {
                    leftJoyHold=true;
                } else {
                    rightJoyHold=true;
                }

                if (e.pointerType === 'mouse') {
                    mouseDraggingSide = side; // Lock the mouse to this specific container
                } else {
                    activePointers[side] = e.pointerId; // Lock touch pointer ID
                }

                if (e.target.setPointerCapture) {
                    e.target.setPointerCapture(e.pointerId);
                }
            } else {
                activePointers[side] = null;

                joy[side].style.left="50%";
                joy[side].style.top="50%";
                joyRead[side].isHeld=false;
                joyRead[side].ratio=0;
                joyRead[side].tx=0;
                joyRead[side].ty=0;
                if(touchIndex['touch1']==side) {
                    touchIndex['touch1']=null;
                }
                if(touchIndex['touch2']==side) {
                    touchIndex['touch2']=null;
                }
                
                if(side=="left") {
                    leftJoyHold=false;
                } else {
                    rightJoyHold=false;
                };

                if (e.pointerType === 'mouse') {
                    if (mouseDraggingSide === side) mouseDraggingSide = null;
                } else {
                    activePointers[side] = null;
                }
            }
        }
        var joyRead={
            left: { isHeld: false, ratio: 0, angle: 0, tx: 0, ty: 0 },
            right: { isHeld: false, ratio: 0, angle: 0, tx: 0, ty: 0 },
        };

        var orbit=25;
        function moveJoy(e) {
            let side = null;

            // 1. Check if this is a mouse event we are dragging
            if (e.pointerType === 'mouse') {
                side = mouseDraggingSide;
            } else { // 2. Otherwise, treat it as a multi-touch event
                if (e.pointerId === activePointers.left) side = "left";
                else if (e.pointerId === activePointers.right) side = "right";
            }

            console.log("Pointer ID:", e.pointerId, "Evaluated Side:", side);

            // Only process if this specific pointer is mapped to a side
            if (side) {
                e.preventDefault();
                //const X = e.touchX;
                //const Y = e.touchY;
                
                // Uniform coordinates for mouse AND touch
                const X = e.clientX;
                const Y = e.clientY;

                //console.log("X: "+X+", Y: "+Y);;
                const container = joy[side + "C"];
                const eL = container.offsetLeft;
                const eT = container.offsetTop;
                const eW = container.clientWidth;
                const eH = container.clientHeight;

                var eX=X-eL; // bounding box = box to circle ?
                if(eX<0) eX=0;

                var eY=Y-eT;
                if(eY<0) eY=0;
                
                if(eX>eW) eX=eW;
                if(eY>eH) eY=eH;

                var pX=eX*100/eW; // percent 0 - 100
                var pY=eY*100/eH;

                var cangle=(Math.atan2(pY-50,pX-50) * 180) / Math.PI;

                var rX=Math.round(Math.cos(cangle*radian)*orbit+25)+25;
                var rY=Math.round(Math.sin(cangle*radian)*orbit+25)+25;
                //console.log("pY: "+pY+" pX: "+pX);
                //console.log("rY: "+pY+" rX: "+pX);
                //console.log("cangle: "+cangle);
                
                if(pX<50) {
                	if(pX<rX) pX=rX;
                } else {
                	if(pX>rX) pX=rX;
                }
                if(pY<50) {
                	if(pY<rY) pY=rY;
                } else {
                	if(pY>rY) pY=rY;
                }

                joy[side].style.left=(pX)+"px";
                joy[side].style.top=(pY)+"px";
                
                var a=50-pX;
                var b=50-pY;
                var c=Math.sqrt( a*a + b*b );
                var v=cangle+180;

                joyRead[side].ratio=c*4;
                joyRead[side].angle=v;
                joyRead[side].isHeld=true;

                //console.log('c * 4 ='+(c*4)+" angle="+cangle);
                // angle = 0 = right but v+180 = left = 0
                // -180/+180 = left             right = +180
                // -90 = forward                backward  + 270
                // 90 = backward                forward = +90

                //console.dir(joyRead[side1]);

                joyRead[side].tx=Math.cos(joyRead[side].angle*radian)*(joyRead[side].ratio*.01);
                joyRead[side].ty=Math.sin(joyRead[side].angle*radian)*(joyRead[side].ratio*.01);
                //let tx=Math.cos(v*radian)*((c*4)*.01);
                //let ty=Math.sin(v*radian)*((c*4)*.01);
                //ty = 1 to -1 forward to backward
                //tx = 1 to -1 left to right

                //console.log("tx: "+tx+" ty: "+ty);
            }
        }
        
        function mouse_position(e) {
            if(mousem.x === null || mousem.y === null ) {
                return; // iniitialize only
            }

            const X=mousep.x;
            const Y=mousep.y;

            var sY=window.scrollY;
            var sX=window.scrollX;

            // isLocked

            // Screen warp handling: if delta is huge, it's the browser centering the hidden cursor
            //if (Math.abs(mousem.x) > 100 || Math.abs(mousem.y) > 100) return;
            
            // 1. Update Yaw (Left/Right)
            //player.rotation -= e.movementX * player.sensitivity;
            player.rotation -= mousem.x * player.sensitivity;
        
            // 2. Update Pitch (Up/Down)
            //player.pitch -= e.movementY * player.sensitivity;
            player.pitch -= mousem.y * player.sensitivity;
        
            // Clamp pitch to 90 degrees up/down
            player.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, player.pitch));

            //if(e.movementX != 0 || e.movementY != 0) {
            if(mousem.x != 0 || mousem.y != 0) {
                idleTime = 0;
            }
        }

        var musicOn=false;

        let useJoysticks = true; // set default start

        // check with clock update in animate
        function check_music() { // only check if musicOn
            if($('musicplayer').paused) {
                music_play(true);
            }
        }
        let tracklist=["./high.mp3","./canyon.mp3"];
        let firstplay=true; // from beginning first time always
        function music_play(reload=false) {
            mF.player = $('musicplayer');
    
            // If we need a new track and are currently playing, start Fade Out
            if (reload && !mF.player.paused && mF.player.volume > 0) {
                mF.fadeDir = -1; 
                return; // Loop will call execute_track_load once volume hits 0
            }

            execute_track_load();

            function execute_track_load() {
                // dice roll to even or odd
                //let pick = (rndMinMax(1, 6) % 2 === 0) ? 0 : 1;
                // more than 2 tracks
                let pick = rndMinMax(0, tracklist.length-1);
                //if(firstplay) {
                //    pick=0; // start high.mp3 first always
                //}
                mF.player.src = tracklist[pick];

                mF.player.addEventListener('loadedmetadata', function() {
                    let beginning = (rndMinMax(1, 6) === 6) ? true : false;
                    if(firstplay) {
                        firstplay=false;
                        beginning=true;
                    }
                    if(!beginning) {
                        const maxTime = mF.player.duration*.8; // avoid last 20%
                        mF.player.currentTime = Math.random() * maxTime;
                        mF.fadeSpeed = .05;
                    } else {
                        console.log("music from beginning track");
                        mF.fadeSpeed = .5;
                    }
                    mF.player.volume = 0;
                    mF.player.play();
                    mF.fadeDir = 1; // Start Fade In
                }, { once: true });

                musicLoaded=true;

                if(!audioContextInitiated) {
                    createAudioContext();
                }
            }
        }
        function music_pause() {
            mF.fadeSpeed = .05;
            mF.fadeDir = -1;
            //$('player').pause(); // replaced with fade
        }
        function music_toggle() {
            musicOn=!musicOn;
            if(musicOn) {
                music_play(true); // reload every time
            } else {
                music_pause();
            }
            //if(!musicOn){
            //    musicOn=true; // keep on for fadeout to finish
            //}
        }
        
        function joy_toggle() {
            useJoysticks=!useJoysticks;

            refreshJoysticks();
        }

        const manager = new THREE.LoadingManager();
        manager.onLoad = function ( ) {
            console.log( "All textures loaded!" );
            // Set a flag that all assets are ready for the simulation
            refList['assetsReady'] = true;
        }; 

        let isLocked = false;
        // isLocked input mode disable joystick
        // joystick enable separate with J ?
        let initialized = false;
        let documentReady = false;
        var tabTime = 0;
        var fakeGround = true;
        var tabHidden = false;
        document.body.onload=function() {
        setTimeout(function() {
            documentReady=true;
            },3000);
        }
        let autoStartMusic = false;
        const normalMatrix = new THREE.Matrix3(); // create once and reuse
        const mvpMatrix = new THREE.Matrix4();
 
        const up = new THREE.Vector3(0, 0, -1); // Depends on your texture        orientation
 
        const tilt = new THREE.Matrix4().makeRotationX(Math.PI / 2); //           tilt used for projector hitproject

        var fogDensity=0.0005; // 0.0008

        const skyColors={
            topColor: [
                [100,94,160], // morning 1
                [100,106,190], // day begin 3
                [101,115,185], // midday 5
                [100,105,170], // day end 3
                [55,35,140],   // evening 1
                [14,7,60],     // night begin 3
                [1,0,5],     // midnight 5 (5 or 35 blue)
                [8,10,56],     // night end 3
            ],
            bottomColor: [
                [165,145,175],// morning
                [164,178,160],// day begin
                [165,174,204],// midday
                [178,170,180],// day end
                [215,87,137], // evening
                [130,30,45],   // night begin
                [20,10,30],     // midnight
                [25,40,75],   // night end
            ],
        };
        
        // One-time setup
        var loading=0;
        var cDensity=0.5;
        var skyDepthOn=true;
        var musicLoaded=false;
        var audioContextInitiated=false;
        var audioContext, source, analyser, bufferLength, dataArray;
        var sunTime = {}; // theta orient around horizontally, phi vertical/      altitude
        var moonTime = {};
        var sunOrbit = 1000;

        let refList = {
            "twily": null,
            "camera": null,
            "cameraP": null,
            "cameraO": null,
            "sunLight": null,
            "renderer": null,
            "playerGroup": null,
            "twilyInstance": [],
            "twilyMaterial": null,
            "twilyTextureNude": null,
            "twilyTextureUnderwear": null,
        }
        function init() {
            scene = new THREE.Scene();

            sunTime=getDirection(170,90); // theta orient around    horizontally, phi vertical/altitude
            moonTime=getDirection(-170,-90); // theta orient around horizontally, phi vertical/altitude

            // Lights (PBR-friendly)
            // light intensities may be separate handled in custom shaderrs*
            const ambientLight = new THREE.AmbientLight(0xffffff, 0.05); // Increased ambient for better brightness
            scene.add(ambientLight);
            const sunLight = new THREE.DirectionalLight(0xffffff, 1.5); // Increased intensity
            
            scene.fog = new THREE.FogExp2(0xaaccff, fogDensity);

            const aspect = window.innerWidth / window.innerHeight;
            //const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 10000);
            const cameraP = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
            // 2. Set up the camera using the aspect ratio
            const cameraO = new THREE.OrthographicCamera(
                -viewSize * aspect,  // left
                 viewSize * aspect,  // right
                 viewSize,           // top
                -viewSize,           // bottom
                 0.1,                // near
                 10000               // far
            );

            //camera.position.set(-46.15, 1908.31, -2053.56); // far view debug
            //camera.lookAt(0, 0, -100); // Look forward along -z
            //camera.rotation.set(-6.0*radian, 180.0*radian, 0.0); // (if not using OrbitControls)
            cameraP.position.set(0, 0, 0);
            cameraO.position.set(0, 0, 0);
            
            cameraP.updateProjectionMatrix();
            cameraO.updateProjectionMatrix();
            
            const cameraGroup = new THREE.Group();
            scene.add(cameraGroup);

            cameraGroup.add(cameraP);
            cameraGroup.add(cameraO);

            const playerGroup = new THREE.Group();
            scene.add(playerGroup);

            playerGroup.position.set(2.0, player.height, 3.0);
            playerGroup.add(cameraGroup);

            // 2. Renderer
            const renderer = new THREE.WebGLRenderer({ antialias: true, stencil: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(window.devicePixelRatio);
            renderer.shadowMap.enabled = false;
            //renderer.shadowMap.type = THREE.PCFSoftShadowMap;
            //document.body.appendChild(renderer.domElement);
            $('mainframe').appendChild(renderer.domElement);

            
            // One-time setup (after main sunLight)
            // To use LinearFilter for VSMs, you might need more specific texture handling:
             //renderer.shadowMap.type = THREE.VSMShadowMap; // This uses LinearFilter by default
            // If using VSM and needing manual control:
             //renderer.shadowMap.type = THREE.VSMShadowMap; // looks great but heavy - enable msgFilter where Wrap and shadowinit x)
                
            renderer.sortObjects = true;
            renderer.autoClear = false; // Prevents implicit clearing between renders
            renderer.autoClearColor = false;
            renderer.autoClearDepth = false;
            renderer.autoClearStencil = false;
            //renderer.shadowMap.autoUpdate = false; // Stop automatic shadow map update

            refList['sunLight']=sunLight;
            refList['cameraP']=cameraP;
            refList['cameraO']=cameraO;
            refList['camera']=cameraGroup;
            refList['playerGroup']=playerGroup;
            refList['renderer']=renderer;

            clock = new THREE.Clock();

            preload_twily();

            grid_toggle();
            
            // 5. Input Listeners
            window.addEventListener('keydown', (e) => keys[e.code] = true);
            window.addEventListener('keyup', (e) => keys[e.code] = false);
            
            // 6. Interaction Setup
            raycaster = new THREE.Raycaster();
            mouse = new THREE.Vector2(); // -1 0 1
            mousep = new THREE.Vector2(); // 0 to w/h
            mousem = new THREE.Vector2(); // mx/my delta
            //window.addEventListener('click', onMouseClick);
            //console.dir(document.getElementsByTagName("canvas")[0]);
            //document.getElementsByTagName("canvas")[0].addEventListener('click', onMouseClick);
            $('overlay').addEventListener('click', onMouseClick);

            window.addEventListener('resize', onWindowResize);

            document.addEventListener("visibilitychange", function() {
               if (document.hidden){
                    //console.log("Browser tab is hidden")
                    tabTime=new Date().getTime();
                    //tabTime=lastTime;
                    if(isLocked || useJoysticks) {
                        fakeGround=true;
                        tabHidden=true;
                        //gravityInitialized=false;
                        console.log('tab hidden in game mode');
                    }
               } else {
                    tabHidden=false;
                    console.log('tab unhidden');
                    //fakeGround=false;
                    //console.log("Browser tab is visible")
                    var nTime=new Date().getTime();
                    //if(nTime-tabTime>(tabRefS*1000) && tabTime!=0) {
                    //    location.reload();
                    //} else {
                        //tabTime=nTime-tabTime;
                    //}
               }
            });

            window.addEventListener('keydown', (e) => {
                keys[e.code] = true;
            
                // Toggle Play/Edit Mode
                if (e.code === 'KeyG') {
                    if (document.pointerLockElement) {
                        document.exitPointerLock();
                    } else {
                        // A 50ms delay bypasses the Chrome "already locked/requesting" glitch
                        setTimeout(() => {
                            refList["renderer"].domElement.requestPointerLock();
                        }, 50);
                    }
                }
                if (e.code === 'KeyC') {
                    player.isCrouching = !player.isCrouching;
                }
                //if (e.code === 'Period') { // .
                if (e.code === 'KeyM') {
                    music_toggle();
                }
                if (e.code === 'KeyO') {
                    cameraMode++;
                    if(cameraMode>2) cameraMode=0;

                    switch(cameraMode) {
                        case 0:
                            refList['cameraP'].fov = 75;
                            refList["cameraP"].updateProjectionMatrix();
                            break;
                        case 1:
                            orthoMode=!orthoMode;
                            break;
                        case 2:
                            orthoMode=!orthoMode;
                            refList['cameraP'].fov = vFOV;
                            refList["cameraP"].updateProjectionMatrix();
                            break;
                        default:
                    }
                }
                if (e.code === 'KeyI') {
                    wireframeOn=!wireframeOn;

                    refList['twilyMaterial'].wireframe=wireframeOn;

                    //for(let i=0;i<instanceIdx;i++) {
                    //    refList['twilyInstance'][i]['twily'].wireframe=wireframeOn;
                    //}
                }
                if (e.code === 'KeyU') {
                    // underwear / clothes toggle
                    nudeOn=!nudeOn;
                    let newtex=refList['twilyTextureNude'];
                    if(!nudeOn) {
                        newtex=refList['twilyTextureUnderwear'];
                    }
                    refList['twilyMaterial'].uniforms.emissiveMap.value=newtex;
                    //for(let i=0;i<instanceIdx;i++) {
                        
                    //}
                    refList['twilyMaterial'].needsUpdate = true;
                }

                if (e.code === 'KeyJ') {
                    if (!isLocked) {
                        useJoysticks=!useJoysticks;

                        refreshJoysticks();
                    }
                }

                //if (e.code === 'KeyP') {
                //    refList["camera"].position.copy(refList["sunLight"].position);
                //}
                //console.log(e.code);
                if(e.code=="NumpadSubtract" || e.code=="Minus") {
                    musicVolume-=5;
                    if(musicVolume<0) musicVolume=0;
                    console.log("Volume down: "+musicVolume);
                    $('musicplayer').volume=musicVolume*.01;
                }
                if(e.code=="NumpadAdd" || e.code=="Plus") {
                    musicVolume+=5;
                    if(musicVolume>100) musicVolume=100;
                    console.log("Volume up: "+musicVolume);
                    $('musicplayer').volume=musicVolume*.01;
                }
            });

            document.addEventListener('pointerlockchange', () => {
                isLocked = document.pointerLockElement === refList["renderer"].domElement;

                ovrJoysticks=isLocked;
                refreshJoysticks();

                if (isLocked) {
                    document.body.classList.add('is-locked');
                } else {
                    document.body.classList.remove('is-locked');
                }
            
                // Visual feedback (optional)
                //const ui = document.getElementById('ui');
                //ui.style.border = isLocked ? "2px solid lime" : "2px solid red";
                //ui.innerHTML = isLocked ? "<b>MODE: PLAY</b> (G to Edit)" : "<b>MODE: EDIT</b> (G to Play)";
            });

            document.addEventListener('wheel', (e) => {
                //const step=e.deltaY;
                const step=.5;
                //console.log("step="+step);
                //if(e.deltaY>0) {
                //    markScale+=step
                //} else {
                //    markScale-=step
                //}
                //if(markScale<1) markScale=1;
                //else if(markScale>5) markScale=5;
            });

            window.addEventListener('pointerdown', (e) => {
                // Standard joystick/window checks...
            
                // Only cache touch pointers for pinching if we aren't dragging UI elements
                if (e.pointerType === 'touch' && !leftJoyHold && !rightJoyHold) {
                    pinchPointers.push(e);
                }
            });

            window.addEventListener('mousemove', (e) => {
                if(isLocked) {
                    let mX=e.movementX || 0;
                    let mY=e.movementY || 0;

                    // --- FIREFOX SKYWARD DRIFT SANITIZATION ---
                  //  if (mY !== 0 && mY === lastMoveY) {
                  //      consecutiveIdenticalDeltas++;
                  //  } else {
                  //      consecutiveIdenticalDeltas = 0;
                  //  }
                  //  lastMoveY = mY;

                  //  if (consecutiveIdenticalDeltas >= 1) mY = 0; // Kill the infinite loop loop
                    // ------------------------------------------

                    mousem.x = mX;
                    mousem.y = mY;
                    mouse_position(e);
                }
                //if(markOn) {
                //if (markOn && !leftJoyHold && !rightJoyHold) {
                //    mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
                //    mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
                //}
            });
            // window.addEventListener('pointermove', moveJoy);
            window.addEventListener('pointermove',function(e) {
                if(leftJoyHold || rightJoyHold) {
                    moveJoy(e);
                }
                // PINCH TO ZOOM LOGIC: Trigger only if 2 fingers are touching the background
                else if (pinchPointers.length === 2 && e.pointerType === 'touch') {
                    e.preventDefault();

                    // Update the coordinates for the current moving finger in our cache
                    for (let i = 0; i < pinchPointers.length; i++) {
                        if (e.pointerId === pinchPointers[i].pointerId) {
                            pinchPointers[i] = e;
                            break;
                        }
                    }

                    // Calculate current distance between the two cached fingers
                    const dx = pinchPointers[0].clientX - pinchPointers[1].clientX;
                    const dy = pinchPointers[0].clientY - pinchPointers[1].clientY;
                    const currentDist = Math.sqrt(dx * dx + dy * dy);

                    //if (prevPinchDist > 0) {
                    //    const step = 0.1; // Smooth stepping value for mobile scale adjustments

                    //    if (currentDist < prevPinchDist) {
                    //        // Fingers moving closer together -> Zoom Out / Grow Scale
                    //        markScale += step;
                    //    } else if (currentDist > prevPinchDist) {
                    //        // Fingers moving apart -> Zoom In / Shrink Scale
                    //        markScale -= step;
                    //    }

                    //    // Bound the scale exactly like your wheel event bounds it
                    //    if (markScale < 1) markScale = 1;
                    //    else if (markScale > 5) markScale = 5;
                    //}

                    prevPinchDist = currentDist;
                }
            },{ passive: false });
            window.addEventListener('touchmove',function(e) {
                e.preventDefault(); 
                //if(markOn) {
                //if (markOn && !leftJoyHold && !rightJoyHold) {
                //    mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
                //    mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
                //}
            }, { passive: false }); // <--- THIS is critical
            

            // Remove touchstart, touchend, mousedown, mouseup
            // Use pointerdown and pointerup for everything
            $('leftJoyContainer').addEventListener('pointerdown', (e) => { holdJoy(e, true, 'left') });
            $('rightJoyContainer').addEventListener('pointerdown', (e) => { holdJoy(e, true, 'right') });

            // Use window for releases so you catch them even if the finger slides off the joystick
            window.addEventListener('pointerup', (e) => {
                if (e.pointerType === 'mouse') {
                    if (mouseDraggingSide) {
                        holdJoy(e, false, mouseDraggingSide);
                    }
                } else {
                    // Multi-touch fallback
                    if (e.pointerId === activePointers.left) holdJoy(e, false, 'left');
                    if (e.pointerId === activePointers.right) holdJoy(e, false, 'right');
                }

                if (e.pointerType === 'touch') {
                    removePinchPointer(e);
                }
            });
            window.addEventListener('pointercancel', (e) => {
                if (e.pointerType === 'touch') {
                    removePinchPointer(e);
                }
            });
        }
        
        function onMouseClick(event) {
            // Calculate mouse position in normalized device coordinates
            mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

            raycaster.setFromCamera(mouse, refList["camera"]);
            const intersects = raycaster.intersectObjects(scene.children);

            if (intersects.length > 0) {
                const hit = intersects[0];
                console.log("Hit:", hit.object.name, "at", hit.point);
                // logic for placing or selecting object would go here
            }
        }

        const downRay = new THREE.Raycaster();
        const downVec = new THREE.Vector3(0, -1, 0);
       
        let idleTime=0; // keeps count
        let activeTime=10; // cap at 10s
        let sprintSpeed = 1.8;
        function handleMovement(delta,current,lapse) {
            if(!initialized) return;
            if(delta>.1) delta=.1; // delta cap for vel y fall after hidden tab

            let currentSpeed = player.speed;
            //if (keys['ShiftLeft']) currentSpeed *= sprintSpeed;
            // built in sprint at 99%
            let isSprinting=false;
            let joySprint=Math.abs(joyRead['left'].tx)+Math.abs(joyRead['left'].ty);
            if(joySprint>=.99 || keys['ShiftLeft']) {
                currentSpeed *= sprintSpeed;
                isSprinting=true;
            }
                
            // 1. Determine Direction Scalars (Cancels out if both keys pressed)
            let moveForward = 0;
            if (keys['KeyW'] || keys['ArrowUp']) moveForward += 1;
            if (keys['KeyS'] || keys['ArrowDown']) moveForward -= 1;
 
            let moveStrafe = 0;
            let turnDir = 0;
            if(isLocked) { // play mode controls
                //if (keys['KeyQ']) document.exitPointerLock();
                if (keys['KeyE']) turnDir -= 1;
                if (keys['KeyQ']) turnDir += 1;
        
                if (keys['KeyA']) moveStrafe -= 1;
                if (keys['KeyD']) moveStrafe += 1;
                if (keys['ArrowLeft']) turnDir += 1;
                if (keys['ArrowRight']) turnDir -= 1;
            } else { // edit mode controls
                if (keys['KeyE']) moveStrafe += 1;
                if (keys['KeyQ']) moveStrafe -= 1;

                if (keys['KeyA'] || keys['ArrowLeft']) turnDir += 1;
                if (keys['KeyD'] || keys['ArrowRight']) turnDir -= 1;
            }

            if(useJoysticks) {
                moveForward+=joyRead['left'].ty;
                moveStrafe-=joyRead['left'].tx;

                if(moveForward>1) moveForward=1;
                else if(moveForward<-1) moveForward=-1;

                if(moveStrafe>1) moveStrafe=1;
                else if(moveStrafe<-1) moveStrafe=-1;

                // smooth camera joy
                let eRx=easeIn(joyRead['right'].tx);
                let eRy=easeIn(joyRead['right'].ty);
                let eRxn=(joyRead['right'].tx<0)?true:false;
                let eRyn=(joyRead['right'].ty<0)?true:false;
                if(eRxn) eRx=-eRx;
                if(eRyn) eRy=-eRy;

                //turnDir+=joyRead['right'].tx;
                turnDir+=eRx;

                // up / down
                //player.pitch += joyRead['right'].ty * delta;
                player.pitch += eRy * delta;
                player.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, player.pitch));
            }
        
            // 2. Apply Turning
            const turnSpeed = 2.5 * delta;
            player.rotation += turnDir * turnSpeed;

            // 3. Calculate Final Movement Vector
            // We clone the vectors so we don't mutate the camera's base orientation
            const forwardVec = new THREE.Vector3(0, 0, -1).applyQuaternion(refList["playerGroup"].quaternion);
            forwardVec.y = 0; 
            forwardVec.normalize();
        
            const rightVec = new THREE.Vector3().crossVectors(forwardVec, new THREE.Vector3(0, 1, 0)).normalize();
        
            // Reset horizontal velocity but keep vertical (for gravity/jumping)
            player.velocity.x = 0;
            player.velocity.z = 0;
        
            // Add Forward/Back contribution
            if (moveForward !== 0) {
                player.velocity.add(forwardVec.multiplyScalar(moveForward * currentSpeed));
            }
            // Add Strafe contribution
            if (moveStrafe !== 0) {
                player.velocity.add(rightVec.multiplyScalar(moveStrafe * currentSpeed));
            }
            
            // Add idle movement to camera in a Lemniscate of Gerono (infinity symbol or a figure-eight (8))
            if(moveForward != 0 || moveStrafe != 0 || turnDir != 0) {
                idleTime=0;
                activeTime+=delta*10;
                if(activeTime>10) activeTime=10;
            } else {
                if(idleTime>1) {
                    activeTime-=delta;
                    if(activeTime<0) activeTime=0;
                }
            }
        
            // https://gemini.google.com/share/18a942a56efd
            // Natural Camera / Idle Sway / Camera Bobbing
            // Idle Breathing / Camera Sway / Ambient Motion
            //const intensity=.1*(Math.min(idleTime,1));
            const intensity=0.0; // 0.0 - 1.0 ?
            const angle=current/(lapse*.000001);
            // pitch needs to oscillate twice as fast for infinity symbol pattern
            const addpit=Math.sin(angle * radian * 2)*intensity*.25;
            const addrot=Math.sin(angle * radian)*(intensity*2);

            // 4. Update Camera Orientation
            refList["playerGroup"].quaternion.setFromEuler(new THREE.Euler(0, player.rotation, 0, 'YXZ'));
            refList["camera"].quaternion.setFromEuler(new THREE.Euler(player.pitch+addpit, addrot, 0, 'YXZ'));

            // head bob walking
            // strideSpeed: slow for walk, fast for sprint
            const intensityBob=0.0;
            const strideSpeed = isSprinting ? 2.0 : 1.0;
            const walkAngle = current / (lapse * 0.000000025) * strideSpeed;
            
            // 1. Vertical Bob (Position Y) - Moves up/down twice per stride
            const bobY = Math.sin(walkAngle * radian * 2.0) * (intensityBob * 0.5);
            
            // 2. Side Sway (Position X or Rotation Z) - Moves once per stride
            const bobX = Math.cos(walkAngle * radian) * (intensityBob * .2);
            
            // 3. Apply to Camera Position (Mental 'Neck' Offset)
            const bobMove=new THREE.Vector3(bobX,bobY,0);
            //const bobMove=new THREE.Vector3(0,0,0,);
            //console.log("bobX: "+bobX+" bobY: "+bobY);
            
            // 4. Subtle Tilt (Roll) - Adds a lot of realism
            const bobRoll = Math.cos(walkAngle * radian) * (intensityBob * 0.01);
            if(intensity<=0) { // or else will override idle above
                //refList["camera"].quaternion.setFromEuler(new THREE.Euler(player.pitch, player.rotation, bobRoll, 'YXZ'));
                refList["camera"].quaternion.setFromEuler(new THREE.Euler(player.pitch, 0, bobRoll, 'YXZ'));
            }
        
            // 5. Physics & Gravity
            if (((player.isGrounded || (doublejumpready>0 && doublejumpdelta>0))) && keys['Space']) {
                keys['Space']=false;
                player.velocity.y = 6.0
                player.isCrouching = false;
                if(!player.isGrounded) {
                    doublejumpready--;
                    doublejumpdelta=0;
                    console.log("double jump");
                } else {
                    doublejumpdelta=0.6;
                    console.log("single jump");
                }
                player.isGrounded = false;
            }
        
            //refList["playerGroup"].position.add(player.velocity.clone().multiplyScalar(delta));
            const nextPos = refList["playerGroup"].position.add(player.velocity.clone().multiplyScalar(delta));
            refList["camera"].position.copy(bobMove);
            
            refList["playerGroup"].position.copy(nextPos);
         
            const targetHeight = player.isCrouching ? 0.8 : 1.7;
            player.height = THREE.MathUtils.lerp(player.height, targetHeight, 0.1);
        
            if (refList["playerGroup"].position.y < player.height) {
                refList["playerGroup"].position.y = player.height;
                player.velocity.y = 0;
                player.isGrounded = true;
                doublejumpready=1;
                doublejumpdelta=0;
            }
            
            player.velocity.y -= 15.0 * delta;
        }

        const viewSize = 2; // This controls your zoom level (height of the view)
        // 1. Calculate vFOV for 300mm lens (with 24mm sensor height)
        // match blender camera at 300mm focal length about 4.1deg fov
        const vFOV = 2 * Math.atan(24 / (2 * 300)) * (180 / Math.PI); // approx 4.12

        function onWindowResize() {
            refList["cameraP"].aspect = window.innerWidth / window.innerHeight;
            setTimeout(() => {
                const width = window.innerWidth;
                const height = window.innerHeight;
                // 1. Target your structural container wrapper 
                //const container = document.getElementById('mainframe') || $('mainframe');
                //const width = container.clientWidth || window.innerWidth;
                //const height = container.clientHeight || window.innerHeight;
                
                const nextAspect = width / height;
                //refList["renderer"].setSize(width, height, false);
 
                // 2. Proportional Frustum Matching (Mimics a Perspective FOV lock)
                if (width >= height) {
                    // Landscape screens: scale width horizontally, keep height uniform
                    refList["cameraO"].left = -viewSize * nextAspect;
                    refList["cameraO"].right = viewSize * nextAspect;
                    refList["cameraO"].top = viewSize;
                    refList["cameraO"].bottom = -viewSize;
                } else {
                    // Portrait screens: scale height vertically, keep width uniform
                    refList["cameraO"].left = -viewSize;
                    refList["cameraO"].right = viewSize;
                    refList["cameraO"].top = viewSize / nextAspect;
                    refList["cameraO"].bottom = -viewSize / nextAspect;
                }

                //refList["renderer"].setSize(window.innerWidth, window.innerHeight);
                // 2. Set canvas resolution buffer, passing false so it doesn't break CSS style
                 // 3. Flush updates to the WebGL context
                    refList["cameraO"].updateProjectionMatrix();
                    refList["cameraP"].updateProjectionMatrix();
                    refList["renderer"].setSize(width, height);
            }, 50);
        }
        
        function hideLoadScreen() {
            loading=-1;
            $('loadtxt').innerHTML="Loading...";
            $('loading').style.opacity=0;
            $('loading').style.pointerEvents="none";

            setTimeout(function() { $('loading').style.display="none"; },1000);
            
            refreshJoysticks();
        }

        let mF={ // musicfade
            fadeDir: 0, // 1 for fade-in, -1 for fade-out, 0 for none
            fadeSpeed: .05, // Amount of volume change per second (0.5 = 2 second fade)
            player: null, // ref to music player later
        }
        
        var dd=null;
        var hh=0;
        var mm=0;
        var ss=0;
        var phh="00";
        var pmm="00";
        var pss="00";
        var tsp=0;
        var ss_theta=0;
        var ss_phi=0;
        var testval=500; // set 0 - 2000 (500 = noon, 1500 = midnight)
        var tmpColor={};
        var testValOn=false; // set
        var testValAuto=true; // set
        
        var waitDensity=0.0;
        var nextDensity=0.0;
        
        let loadtime=0.0;
        let loaddelta=0.0;
        let doublejumpready=1;
        let doublejumpdelta=0.0;
        
        let lastTime = 0;
        //let gradientBG="";
        let updateClock = 0;
        let fadeStatusLast = 0;

        let nextBlink = 0.0;
        let blinkDelta = 0.0;
        let nextAnim = 0.0; // for sync mode // old nextLook
        let lastRnd = 0; // for sync mode
        let syncAnimations = true; // set
        let nextSyncToggle = rndMinMax(10,40);
        let dynamicSyncToggle = true; // set
        let orthoMode = false;
        let cameraMode = 0;
        let wireframeOn = false;
        let nudeOn = true;
        let musicVolume=60;

        let dt=0;
        function animate(time) {
            requestAnimationFrame(animate);

            dt = (time - lastTime) / 1000;
            lastTime = time;
            
            loaddelta += dt;

            idleTime += dt;

            if(refList['twily']) {
            // blink
            // synced blink
           // if(nextBlink<=0.0) {
           //     blinkDelta-=dt*blinkSpeed;
           //     if(blinkDelta<=0.0) {
           //         // trigger once reset
           //         nextBlink=rndMinMax(2,10);
           //         //console.log('twily'+i+') nextblink: '+targetAnim.nextBlink);
           //         blinkDelta=0.0;
           //     } 
           // const blink=(blinkDelta*2.0)-blinkDuration; // from positive max to negative max
            // synced blink

            for(let i=-1;i<instanceIdx;i++) {
                const targetAnim=(i>-1)?refList['twilyInstance'][i]:twilyAnim;
                const targetTwily=(i>-1)?refList['twilyInstance'][i]['twily']:refList['twily'];
                //console.dir(targetTwily);

                const twilypos=new THREE.Vector3();
                twilypos.copy(targetTwily.position);
                const dx = twilypos.x - refList['playerGroup'].position.x;
                const dy = twilypos.z - refList['playerGroup'].position.z;
                const currentDist = Math.sqrt(dx * dx + dy * dy);
                //console.log("currentDist: "+currentDist);

                if(targetAnim.nextLook<=0.0) {
                    targetAnim.nextLook=rndMinMax(1,20);

                    //if(targetAnim.lookWeight<=0.0) {
                    //    targetAnim.lookWeight = 0.8; // sudden interest
                        //console.log("twily"+i+") look start");
                        targetAnim.isLooking=!targetAnim.isLooking;
                        //targetAnim.isLooking=true; // all look always

                    //} else {
                    //    targetAnim.lookWeight -= dt;
                    //    if(targetAnim.lookWeight <= 0.0) {
                    //        targetAnim.lookWeight = 0.0;
                    //    }
                    //    //console.log("twily"+i+") look stop");
                    //}
                } else {
                    targetAnim.nextLook-=dt;
                }
                if(currentDist<10.0) {
                    targetAnim.isLooking=true;
                }

                // Smoothly ease the lookWeight up or down EVERY FRAME based on the active state
                const fadeSpeed = 1.0; // Higher = faster transition, Lower = slower transition
                if (targetAnim.isLooking) {
                    // uncomment to only lookat when not sync
                    //if(syncAnimations) targetAnim.isLooking=false;

                    // Smoothly fade in toward 0.8 maximum interest
                    targetAnim.lookWeight += fadeSpeed * dt;
                    if (targetAnim.lookWeight > 0.8) targetAnim.lookWeight = 0.8;
                    //else if(i==-1) console.log("twily"+i+") increasing lookweight="+targetAnim.lookWeight);
                } else {
                    // Smoothly fade out toward 0.0 complete rest
                    targetAnim.lookWeight -= fadeSpeed * dt;
                    if (targetAnim.lookWeight < 0.0) targetAnim.lookWeight = 0.0;
                    //else if(i==-1) console.log("twily"+i+") reducing lookweight="+targetAnim.lookWeight);
                }

                // individual blink
                if(targetAnim.nextBlink<=0.0) {
                    // trigger continous
                    targetAnim.blinkDelta-=dt*blinkSpeed;
                    if(targetAnim.blinkDelta<=0.0) {
                        // trigger once reset
                        targetAnim.nextBlink=rndMinMax(2,10);
                        //console.log('twily'+i+') nextblink: '+targetAnim.nextBlink);
                        targetAnim.blinkDelta=0.0;
                    }
                    const blink=(targetAnim.blinkDelta*2.0)-blinkDuration; // from positive max to negative max
                // individual blink
                    if(blink>0.0) {
                        // positive
                        // shapemesh.=blink;

                        targetAnim.shapemesh.morphTargetInfluences[1] = 1.0-blink;
                        targetAnim.shapemesh.morphTargetInfluences[2] = 1.0-blink; 
                        //console.log('twily'+i+') blink down: '+blink);
                    } else {
                        // negative
                        // shapemesh.=Math.abs(blink);
                        targetAnim.shapemesh.morphTargetInfluences[1] = 1.0-Math.abs(blink);
                        targetAnim.shapemesh.morphTargetInfluences[2] = 1.0-Math.abs(blink); 
                        //console.log('twily'+i+') blink up: '+Math.abs(blink));
                    }
                // individual blink
                } else {
                    targetAnim.nextBlink-=dt;
                    //console.log('twily'+i+') nextBlink: '+targetAnim.nextBlink);

                    if(targetAnim.nextBlink<=0.0) {
                        // trigger once
                        targetAnim.blinkDelta=blinkDuration;
                        //console.log('twily'+i+') blink start');
                    }
                }
                // individual blink
                // targetAnim can access blink on both
                //targetAnim.nextBlink
                //targetAnim.blinkDelta
            }
            // synced blink
           // } else { // if nextblink>0.0
           //     nextBlink-=dt;
           //     //console.log('twily'+i+') nextBlink: '+targetAnim.nextBlink);

           //     if(nextBlink<=0.0) {
           //         // trigger once
           //         blinkDelta=blinkDuration;
           //         //console.log('twily'+i+') blink start');
           //     }
           // }
            // synced blink
            } // if twily

            if(dynamicSyncToggle) {
                if(nextSyncToggle<=0.0) {
                    nextSyncToggle=rndMinMax(30,90);
                    syncAnimations=!syncAnimations;
                } else {
                    nextSyncToggle-=dt;
                }
            }
            if(syncAnimations) {
                nextAnim -= dt;
                if(nextAnim <= 0) {
                    let idleReturn=(lastRnd>0)?true:false;
                    let rndAnim=0;
                    if(!idleReturn) {
                        rndAnim = rndMinMax(1,6); 
                        if(rndAnim>=5) {
                            nextAnim=rndMinMax(10,30); // duration ponyidle
                        } else if(rndAnim>=3) {
                            nextAnim=rndMinMax(10,30); // duration squats
                        } else {
                            nextAnim=3.9167; // duration look
                        }
                    } else {
                        nextAnim=rndMinMax(10,40); // wait rndAnim
                    }
                    lastRnd=rndAnim;
                    for(let i=-1;i<instanceIdx;i++) {
                        const targetAnim=(i>-1)?refList['twilyInstance'][i]:twilyAnim;

                        if(rndAnim>=5) {
                            // multi animation with return
                            targetAnim.returnAnim = playSequence(
                                targetAnim.actions.idletopony,
                                targetAnim.actions.idlepony,
                                targetAnim.actions.ponytoidle,
                                targetAnim.actions.idle,
                                0.6,
                                0.8,
                                i
                            );
                        } else if(rndAnim>=3) {
                            // single animation with return
                            crossFadeTo(targetAnim.actions.squats,0.6,true,i);
                            //console.log('crossfade to idlelook');
                            targetAnim.returnAnim = function() {
                                crossFadeTo(targetAnim.actions.idle,0.6,true,i);
                                //console.log('crossfade to idle');
                                targetAnim.returnAnim = null
                            };
                        } else if(rndAnim>=1) {
                            // single animation with return
                            crossFadeTo(targetAnim.actions.idlelook,0.6,true,i);
                            //console.log('crossfade to idlelook');
                            targetAnim.returnAnim = function() {
                                crossFadeTo(targetAnim.actions.idle,0.6,true,i);
                                //console.log('crossfade to idle');
                                targetAnim.returnAnim = null
                            };
                        } else { // idle return?
                            crossFadeTo(targetAnim.actions.idle,0.6,true,i);
                        }
                    }
                }
            } else { // individual animations
                for(let i=-1;i<instanceIdx;i++) {
                    const targetAnim=(i>-1)?refList['twilyInstance'][i]:twilyAnim;

                    targetAnim.nextAnim -= dt;
                    if(targetAnim.nextAnim <= 0) {
                        if(targetAnim.returnAnim == null) {
                            var rndAnim = rndMinMax(1,6);

                            if(rndAnim>=5) {
                                // multi animation with return
                                targetAnim.returnAnim = playSequence(
                                    targetAnim.actions.idletopony,
                                    targetAnim.actions.idlepony,
                                    targetAnim.actions.ponytoidle,
                                    targetAnim.actions.idle,
                                    0.6,
                                    0.8,
                                    i
                                );
                                
                                targetAnim.nextAnim=rndMinMax(10,30); // duration ponyidle
                            } else if(rndAnim>=3) {
                                // single animation with return
                                crossFadeTo(targetAnim.actions.squats,0.6,true,i);
                                //console.log('crossfade to idlelook');

                                targetAnim.returnAnim = function() {
                                    crossFadeTo(targetAnim.actions.idle,0.6,true,i);
                                    //console.log('crossfade to idle');
                                    targetAnim.returnAnim = null
                                };
                                
                                targetAnim.nextAnim=rndMinMax(10,30); // duration squats
                            } else {
                                // single animation with return
                                crossFadeTo(targetAnim.actions.idlelook,0.6,true,i);
                                //console.log('crossfade to idlelook');

                                targetAnim.returnAnim = function() {
                                    crossFadeTo(targetAnim.actions.idle,0.6,true,i);
                                    //console.log('crossfade to idle');
                                    targetAnim.returnAnim = null
                                };

                                targetAnim.nextAnim=3.9167; // duration look
                            }
                        } else {
                            targetAnim.returnAnim();

                            targetAnim.nextAnim=rndMinMax(10,40); // wait rndAnim
                        }
                    }
                }
            }

            // CAP DELTA: If the tab was hidden, delta might be huge.
            // We "freeze" time at 0.1s max to prevent quantum tunneling through floors.
            if (dt > 0.1) dt = 0.1;

            if(loading!=-1 && loaddelta>0.3) {
                loadtime += loaddelta;
                loaddelta=0.0;
                loading++;
                if(loading>3) loading=0;
                let loadtxt="Loading";
                for(let i=0;i<loading;i++) loadtxt+=".";
                if(loadtime>30.0) {
                    loadtxt+="<br /><span style=\"white-space: nowrap; break-word: none; font-size: 10pt;\">(Taking a long time to load models,<br />try refresh in case it got stuck again x))";
                }
                $('loadtxt').innerHTML=loadtxt;
                //if(initloading) {
                //    const loadtxtwidth=$('loadtxt').clientWidth;
                //    $('loadtxt').style.width=(loadtxtwidth+5)+"px"; // hardset
                //    $('loadtxt').style.textAlign="left";
                //    initloading=false;
                //}
            }

            if(doublejumpdelta>0) {
                doublejumpdelta-=dt;
            }
                
            const delta = clock.getDelta(); // For mixers
            const globalTime = clock.getElapsedTime();
            
            //let barHeight=cDensity;
            //if((musicOn || fadeStatusLast != 0) && audioContextInitiated) {
            //    if(winState['audio']['chk_music_clouds']) {
            //        // Get the current frequency data
            //        analyser.getByteFrequencyData(dataArray);

            //        // Draw bars for each frequency bin
            //        for (let i = 0; i < bufferLength; i++) {
            //            barHeight += dataArray[i]/255; // Amplitude value (0-255)
            //        }
            //        barHeight/=bufferLength;
            //        //barHeight*=2;
            //    }
            //}
            //refList["skyMaterial"].uniforms.cDensity.value=cDensity;
            //refList["customDepthMat"].uniforms.cDensity.value=cDensity;
            //refList["skyMaterial"].uniforms.cDensity.value=barHeight;
            //refList["customDepthMat"].uniforms.cDensity.value=barHeight;
            //console.log("cDensity="+cDensity);

            // --- MUSIC FADE ENGINE ---
            if (mF.fadeDir !== 0) {
                mF.player = $('musicplayer');
                let targetVol = musicVolume*.01;

                if (mF.fadeDir === 1) { // Fading In
                    mF.player.volume = Math.min(targetVol, mF.player.volume + (mF.fadeSpeed * dt));

                    const fadeStatusNew=Math.floor(mF.player.volume*100);
                    //const fadeStatusNew=Math.floor((fadeStatusVol*100)/winState['audio']['slider_vol1']);
                    if(fadeStatusLast!=fadeStatusNew) {
                        fadeStatusLast=fadeStatusNew;
                    }
                    if (mF.player.volume >= targetVol) {
                        mF.fadeDir = 0;
                    }
                    //console.log("music fade in vol="+mF.player.volume);
                } 
                else if (mF.fadeDir === -1) { // Fading Out
                    mF.player.volume = Math.max(0, mF.player.volume - (mF.fadeSpeed * dt));
                    //console.log("music fade out vol="+mF.player.volume);
                    const fadeStatusNew=Math.floor(mF.player.volume*100);
                    //const fadeStatusNew=Math.floor((fadeStatusVol*100)/winState['audio']['slider_vol1']);
                    if(fadeStatusLast!=fadeStatusNew) {
                        fadeStatusLast=fadeStatusNew;
                    }
                    if (mF.player.volume <= 0) {
                        mF.fadeDir = 0;
                        
                        // DECISION: Reload or just Stop?
                        if (musicOn) {
                            music_play(true); // Logic for picking new track
                            console.log("player new track start");
                        } else {
                            mF.player.pause();   // Logic for manual toggle off
                            console.log("player music paused");
                        }
                    }
                }
            }
            
            var lapse=86400; // realtime
            if(testValOn) {
                lapse=2000; // debugtime testval uncomment
            }
            
            // sun position calculations
            dd=new Date();
            if(updateClock<=0) {
                hh=dd.getHours();
                mm=dd.getMinutes();
                ss=dd.getSeconds();
                phh=(hh<10)?"0"+hh:hh;
                pmm=(mm<10)?"0"+mm:mm;
                pss=(ss<10)?"0"+ss:ss;
                hh=hh*60*60;
                mm=mm*60;
                ss+=mm+hh;
            
                //let tdate=phh+":"+pmm+":"+pss;
                //$('tdate').innerHTML=tdate;
                updateClock=0.49;

                if(musicOn && documentReady) {
                    check_music();
                }
            } else {
                hh=dd.getHours()*60*60;
                mm=dd.getMinutes()*60;
                ss=dd.getSeconds()+mm+hh;
                updateClock-=dt;
            }
            //ss+=(6*60*60); // offset time, - 4-5 hours seem about accurate for sunset at 6pm front
            ss-=(6*60*60);
            if(ss>86400) ss-=86400;
            if(ss<0) ss+=86400;

            if(testValOn) {
                // debugtime
                if(testValAuto) {
                    testval++;
                }
                if(testval>2000) testval-=2000;
                tsp=testval/1000; // debugtime
                ss=testval;
            } else {
                // realtime
                tsp=ss/43200; // realtime comment out for debugtime sunpos
            }

            ss_phi=((tsp)*180)+90;
            if(ss_phi>360) ss_phi-=360;
            
            //ss_theta=(-45);
            var scal=0;
            if(ss_phi<180) {
            // make 86400 into 360 be 0 to 180 and 180 to 360 into 0 to 2 then 0 to 2 into -1 to 0 to 1, then math abs -1 0 1 into 1 0 1 then make 1 0 1 into 0 1 0 with 1- and 0 -1 0 with -1-
                scal=Math.abs((ss_phi/90)-1); // 0 1 0
                if(ss_phi<90) scal=-scal; //negative edge case
            } else {
                scal=1-Math.abs(1-((ss_phi-180)/90)-1); // 0 -1 0
            }
            //ss_theta=(scal*45);
            //let smoothscal=SmoothStep(-1.0,1.0,scal);
            let smoothscal=(easeInOut((scal+1.0)*.5)*2.0)-1.0; // curve the midnight/midday corner point up/down while arriving at center
            ss_theta=(smoothscal*45);

            let ssTun = ss - (25*lapse/100);
            if(ssTun<0) ssTun+=lapse;
            //console.log(Math.abs((ss/(lapse*.5))-1.0));
            const daytime=Math.abs((ssTun/(lapse*.5))-1.0); // 1 0 1
            
            let daynightIntensity=0;
            let ambientIntensity=0;

            //console.log("ss_phi: "+ss_phi+" ss_theta: "+ss_theta+" scal="+scal);
            //console.log(" scal="+scal);

            // phi and theta have been swapped here intentionally to tilt on a different axis than top/bottom~
            sunTime=getDirection(ss_phi,ss_theta); // 90,170 = default front above ground, 180 phi = on ground
            moonTime=getDirection(-ss_phi,-ss_theta); // 90,170 = default front above ground, 180 phi = on ground
            //sunPhi should be 180 at 6am and 6pm, going to 300 at night and 60 on day
            //sunTheta should be 90 at 6pm, -90 or 270 at 6am
           
            if(daytime>=0.49) { // sun below horizon
                refList["sunLight"].position.set(sunTime.x*sunOrbit,sunTime.y*sunOrbit,sunTime.z*sunOrbit);
                daynightIntensity=((daytime - 0.49) * 2.0) * 5;
                ambientIntensity=(1.0 - (daytime * 2.0)) * .3;
                //console.dir(refList["sunLight"].position);
            } else {
                refList["sunLight"].position.set(-moonTime.x*sunOrbit,moonTime.y*sunOrbit,moonTime.z*sunOrbit);
                daynightIntensity=(1.0 - (daytime * 2.0)) * 3;
                ambientIntensity=(daytime * 2.0) * .3;
                //console.dir(refList["sunLight"].position);
            }
            currentColor.lerpVectors(nightTint, dayTint, daytime);
            //refList["sunLight"].color.setFromVector3(currentColor);
            //console.log("daynightIntensity: "+daynightIntensity+" ambientIntensity: "+ambientIntensity+" ("+((daytime>=0.49)?"day":"night")+")"+" daytime = "+daytime);
            const cameraWorldPosition = new THREE.Vector3();
            refList["camera"].getWorldPosition(cameraWorldPosition);

            const twily=refList['twily'];
            if (twily) {
                twily.traverse(child => {
                    if (child.isMesh && child.material && child.material.isShaderMaterial) {

                        child.material.uniforms.cameraPosition.value.copy(cameraWorldPosition);
                        child.material.uniforms.lightDir.value.copy(refList["sunLight"].position).normalize();
                        child.material.uniforms.lightDir2.value.copy(refList["sunLight"].position).normalize().negate();

                        child.material.uniforms.daytime.value=daytime;
                        //child.material.needsUpdate = true;
                    }
                });

                if (twilyAnim.mixer) {
                    twilyAnim.mixer.update(delta);


                    // Capture the clean animation frame before procedural changes alter it!
                    if (twilyAnim.neckbone) {
                        twilyAnim.baseQuat.copy(twilyAnim.neckbone.quaternion);
                    }
                    updateLookAt(twilyAnim, refList['playerGroup'], delta, 90);

                    if(twily.skeleton) {
                        twily.skeleton.update();
                    }
                }


                let instance=null;
                for(let i=0;i<instanceIdx;i++) {
                    if(refList['twilyInstance'][i]) {
                        instance=refList['twilyInstance'][i];
                        instance.mixer.update(delta);


                        // Capture the clean animation frame for instances too!
                        if (instance.neckbone) {
                            instance.baseQuat.copy(instance.neckbone.quaternion);
                        }
                        updateLookAt(instance, refList['playerGroup'], delta, 90);

                        if(instance['twily'].skeleton) {
                            instance['twily'].skeleton.update();
                        }
                    }
                }
            }
            //for(let i=0;i<instanceIdx;i++) {
            //    //refList['twilyInstance']['twily'+i].visible = true;
            //    console.log(refList['twilyInstance']['twily'+i]);
            //}

            // sky color calculations
            var l=0; // segment selection
             
            var t=ss; // range current

            //const hr_1=(4.2*lapse/100); // percentage
            const hr_1=(lapse/24); // percentage
            const hr_3=hr_1*3;
            const hr_4=hr_3+hr_1;
            const hr_5=hr_4+hr_1;
            const hr_9=hr_5+hr_4;
            const hr_12=hr_9+hr_3;
            const hr_13=hr_12+hr_1;
            const hr_16=hr_13+hr_3;
            const hr_21=hr_16+hr_5;

            //ss-=(10*lapse/100);
            //if(ss<0) ss+=lapse; // rollover

            var m=hr_1; // range max
            if(ss>hr_21) { // night end 3
                l=7;
                t=ss-hr_21;
                m=hr_3;
            } else if(ss>hr_16) { // midnight 5
                l=6;
                t=ss-hr_16;
                m=hr_5;
            } else if(ss>hr_13) { // night begin 3
                l=5;
                t=ss-hr_13;
                m=hr_3;
            } else if(ss>hr_12) { // evening 1
                l=4;
                t=ss-hr_12;
                m=hr_1;
            } else if(ss>hr_9) { // day end 3
                l=3;
                t=ss-hr_9;
                m=hr_3;
            } else if(ss>hr_4) { // midday 5
                l=2;
                t=ss-hr_4;
                m=hr_5;
            } else if(ss>hr_1) { // day begin 3
                l=1;
                t=ss-hr_1;
                m=hr_3;
            }
            var l2=l+1;
            //if(l2>3) l2=0;
            if(l2>7) l2=0;
            //console.log("l="+l+" l2="+l2+" t="+t+" m="+m);

            tmpColor['r1']=skyColors['topColor'][l][0];
            tmpColor['g1']=skyColors['topColor'][l][1];
            tmpColor['b1']=skyColors['topColor'][l][2];
            tmpColor['r2']=skyColors['topColor'][l2][0];
            tmpColor['g2']=skyColors['topColor'][l2][1];
            tmpColor['b2']=skyColors['topColor'][l2][2];
            
            tmpColor['r3']=skyColors['bottomColor'][l][0];
            tmpColor['g3']=skyColors['bottomColor'][l][1];
            tmpColor['b3']=skyColors['bottomColor'][l][2];
            tmpColor['r4']=skyColors['bottomColor'][l2][0];
            tmpColor['g4']=skyColors['bottomColor'][l2][1];
            tmpColor['b4']=skyColors['bottomColor'][l2][2];
            
            let p = Math.min(t / m, 1); // progress
            //const eP=p; // linear
            const eP = easeInOut(p); // easedProgress
            //const currentValue = lerp(startValue, endValue, eP);

            tmpColor['rlt']=lerp(tmpColor['r1'],tmpColor['r2'],eP);
            tmpColor['glt']=lerp(tmpColor['g1'],tmpColor['g2'],eP);
            tmpColor['blt']=lerp(tmpColor['b1'],tmpColor['b2'],eP);

            tmpColor['rlb']=lerp(tmpColor['r3'],tmpColor['r4'],eP);
            tmpColor['glb']=lerp(tmpColor['g3'],tmpColor['g4'],eP);
            tmpColor['blb']=lerp(tmpColor['b3'],tmpColor['b4'],eP);

            tmpColor['fr']=lerp(tmpColor['rlb'],tmpColor['rlt'],.66)*.66;
            tmpColor['fg']=lerp(tmpColor['glb'],tmpColor['glt'],.66)*.46;
            tmpColor['fb']=lerp(tmpColor['blb'],tmpColor['blt'],.66)*.16;

            scene.fog = new THREE.FogExp2(new THREE.Color(tmpColor['fr']/255,tmpColor['fg']/255,tmpColor['fb']/255), fogDensity);
            
            const milliseconds=ss+(dd.getMilliseconds()/1000.0);
            
            refList["camera"].updateMatrixWorld();
            
            if(!initialized) return;

            if (!tabHidden) {
                // Optional: wait 2-3 frames to ensure the Raycaster can actually
                // compute intersections against the bounding boxes
                //gravityInitialized = true;
                fakeGround = false;
            }

            handleMovement(delta,milliseconds,lapse);

            if (initialized && refList["renderer"].domElement.width > 0) {
                //refList["renderer"].state.buffers.color.setMask(true);
                //refList["renderer"].state.buffers.depth.setMask(true);
                //refList["renderer"].state.buffers.stencil.setMask(0xff); // Ensure stencil is clearable
                //const gl = refList["renderer"].getContext();
                //const bitmask = gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT;

                //if (bitmask !== 0) {
                    refList["renderer"].clear(true, true, true);
                //}
            
                // render by custom layer order or render all
                if(orthoMode) {
                    refList["renderer"].render(scene, refList["cameraO"]);
                } else {
                    refList["renderer"].render(scene, refList["cameraP"]);
                }
            }
        }

        init();
        animate(0);

var audioFilters=[ // hz,db,obj
    [60,0.0,null],
    [170,0.0,null],
    [310,0.0,null],
    [600,0.0,null],
    [1000,0.0,null],
    [3000,0.0,null],
    [6000,0.0,null],
    [12000,0.0,null],
    [14000,0.0,null],
    [16000,0.0,null]
];

function createAudioContext() {
    audioContextInitiated=true;

    audioContext = new (window.AudioContext || window.webkitAudioContext)();

    // Create a source node from the audio element
    source = audioContext.createMediaElementSource($('musicplayer'));

    // Create an AnalyserNode for audio analysis
    analyser = audioContext.createAnalyser();
    analyser.fftSize = 128; // Number of samples for FFT (higher = more detail)
    bufferLength = analyser.frequencyBinCount; // Half of fftSize
    dataArray = new Uint8Array(bufferLength); // Array to store frequency data

    for(var i=0;i<audioFilters.length;i++) {
        audioFilters[i][2] = audioContext.createBiquadFilter();
        var type="lowshelf";
        if(i>6) {
            type="highshelf";
        } else if(i>3) {
            type="peaking";
        }
        audioFilters[i][2].type=type;
        audioFilters[i][2].frequency.value=audioFilters[i][0];
    }

    // Connect the audio source to the analyser and then to the output (speakers)
    source.connect(analyser);

    analyser.connect(audioFilters[0][2]);
    for(var i=0;i<audioFilters.length-1;i++) {
        audioFilters[i][2].connect(audioFilters[(i+1)][2]);
    }
    //analyser.connect(audioContext.destination);
    audioFilters[9][2].connect(audioContext.destination);
}
    </script>

</body>
</HTML>

Top
©twily.info 2013 - 2026
twily at twily dot info



2 813 524 visits
... ^ v