| |
| |
| |
| |
|
|
| class PusherSimulator { |
| constructor(canvasId) { |
| this.canvas = document.getElementById(canvasId); |
| this.container = this.canvas.parentElement; |
| |
| |
| this.currentStep = 50000; |
| this.isRunning = true; |
| this.speedMultiplier = 1.0; |
| this.episode = 1; |
| this.episodeSteps = 0; |
| this.maxEpisodeSteps = 100; |
| |
| |
| this.dragTarget = null; |
| this.raycaster = new THREE.Raycaster(); |
| this.mouse = new THREE.Vector2(); |
| this.plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); |
| |
| |
| this.tableRadius = 3.5; |
| this.puckPos = new THREE.Vector3(0.8, 0.1, 0.4); |
| this.puckVel = new THREE.Vector3(0, 0, 0); |
| this.goalPos = new THREE.Vector3(1.2, 0.02, -0.6); |
| this.targetObject = "puck"; |
| |
| |
| this.arm = { |
| basePos: new THREE.Vector3(-1.0, 0, 0), |
| l1: 1.4, |
| l2: 1.2, |
| l3: 0.7, |
| theta1: 0.2, |
| theta2: 0.4, |
| theta3: -0.6, |
| theta4: 0.2, |
| tipPos: new THREE.Vector3(0, 0.1, 0) |
| }; |
|
|
| |
| this.tipTrailPoints = []; |
| this.puckTrailPoints = []; |
| this.maxTrail = 60; |
|
|
| |
| this.currentReward = 0; |
| this.distToPuck = 0; |
| this.distToGoal = 0; |
| this.successCount = 0; |
|
|
| this.initThree(); |
| this.initScene(); |
| this.setupEvents(); |
| this.animate = this.animate.bind(this); |
| requestAnimationFrame(this.animate); |
| } |
|
|
| initThree() { |
| this.scene = new THREE.Scene(); |
| this.scene.background = new THREE.Color(0x070b14); |
| this.scene.fog = new THREE.FogExp2(0x070b14, 0.08); |
|
|
| const width = this.container.clientWidth; |
| const height = this.container.clientHeight; |
|
|
| this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); |
| this.setCameraPreset("iso"); |
|
|
| this.renderer = new THREE.WebGLRenderer({ |
| canvas: this.canvas, |
| antialias: true, |
| powerPreference: "high-performance" |
| }); |
| this.renderer.setSize(width, height); |
| this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| this.renderer.shadowMap.enabled = true; |
| this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
|
| |
| this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement); |
| this.controls.enableDamping = true; |
| this.controls.dampingFactor = 0.05; |
| this.controls.maxPolarAngle = Math.PI / 2 - 0.02; |
| this.controls.minDistance = 2; |
| this.controls.maxDistance = 12; |
| this.controls.target.set(0.3, 0.2, 0); |
|
|
| |
| window.addEventListener("resize", () => this.onResize()); |
| } |
|
|
| initScene() { |
| |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); |
| this.scene.add(ambientLight); |
|
|
| const dirLight = new THREE.DirectionalLight(0xa5b4fc, 1.2); |
| dirLight.position.set(5, 10, 7); |
| dirLight.castShadow = true; |
| dirLight.shadow.mapSize.width = 2048; |
| dirLight.shadow.mapSize.height = 2048; |
| dirLight.shadow.camera.near = 0.5; |
| dirLight.shadow.camera.far = 25; |
| dirLight.shadow.camera.left = -5; |
| dirLight.shadow.camera.right = 5; |
| dirLight.shadow.camera.top = 5; |
| dirLight.shadow.camera.bottom = -5; |
| this.scene.add(dirLight); |
|
|
| |
| const cyanLight = new THREE.PointLight(0x00f2fe, 1.5, 8); |
| cyanLight.position.set(1.5, 1.5, -1.0); |
| this.scene.add(cyanLight); |
|
|
| const purpleLight = new THREE.PointLight(0x9d4edd, 1.2, 8); |
| purpleLight.position.set(-1.5, 1.5, 1.5); |
| this.scene.add(purpleLight); |
|
|
| |
| this.createTable(); |
|
|
| |
| this.createRobotArm(); |
|
|
| |
| this.createInteractiveObjects(); |
|
|
| |
| this.createTrails(); |
| } |
|
|
| createTable() { |
| |
| const tableGeo = new THREE.CylinderGeometry(this.tableRadius, this.tableRadius + 0.2, 0.2, 64); |
| const tableMat = new THREE.MeshStandardMaterial({ |
| color: 0x0f172a, |
| roughness: 0.4, |
| metalness: 0.3 |
| }); |
| const table = new THREE.Mesh(tableGeo, tableMat); |
| table.position.y = -0.1; |
| table.receiveShadow = true; |
| this.scene.add(table); |
|
|
| |
| const grid = new THREE.GridHelper(6, 24, 0x00f2fe, 0x1e293b); |
| grid.position.y = 0.005; |
| this.scene.add(grid); |
|
|
| |
| const ringGeo = new THREE.RingGeometry(this.tableRadius - 0.05, this.tableRadius + 0.05, 64); |
| const ringMat = new THREE.MeshBasicMaterial({ |
| color: 0x00f2fe, |
| side: THREE.DoubleSide, |
| transparent: true, |
| opacity: 0.4 |
| }); |
| const ring = new THREE.Mesh(ringGeo, ringMat); |
| ring.rotation.x = -Math.PI / 2; |
| ring.position.y = 0.01; |
| this.scene.add(ring); |
| } |
|
|
| createRobotArm() { |
| this.armGroup = new THREE.Group(); |
| this.armGroup.position.copy(this.arm.basePos); |
|
|
| const metalMat = new THREE.MeshStandardMaterial({ |
| color: 0x1e293b, |
| roughness: 0.2, |
| metalness: 0.8 |
| }); |
| const jointMat = new THREE.MeshStandardMaterial({ |
| color: 0x00f2fe, |
| emissive: 0x00f2fe, |
| emissiveIntensity: 0.3, |
| roughness: 0.3, |
| metalness: 0.5 |
| }); |
| const linkMat = new THREE.MeshStandardMaterial({ |
| color: 0x334155, |
| roughness: 0.3, |
| metalness: 0.6 |
| }); |
|
|
| |
| const baseGeo = new THREE.CylinderGeometry(0.35, 0.45, 0.3, 32); |
| const base = new THREE.Mesh(baseGeo, metalMat); |
| base.position.y = 0.15; |
| base.castShadow = true; |
| this.armGroup.add(base); |
|
|
| |
| this.shoulderJoint = new THREE.Group(); |
| this.shoulderJoint.position.y = 0.3; |
| const j1Sphere = new THREE.Mesh(new THREE.SphereGeometry(0.2, 24, 24), jointMat); |
| j1Sphere.castShadow = true; |
| this.shoulderJoint.add(j1Sphere); |
| this.armGroup.add(this.shoulderJoint); |
|
|
| |
| this.link1 = new THREE.Group(); |
| const link1Mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, this.arm.l1, 24), linkMat); |
| link1Mesh.position.y = this.arm.l1 / 2; |
| link1Mesh.castShadow = true; |
| this.link1.add(link1Mesh); |
| this.shoulderJoint.add(this.link1); |
|
|
| |
| this.elbowJoint = new THREE.Group(); |
| this.elbowJoint.position.y = this.arm.l1; |
| const j2Sphere = new THREE.Mesh(new THREE.SphereGeometry(0.16, 24, 24), jointMat); |
| j2Sphere.castShadow = true; |
| this.elbowJoint.add(j2Sphere); |
| this.link1.add(this.elbowJoint); |
|
|
| |
| this.link2 = new THREE.Group(); |
| const link2Mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, this.arm.l2, 24), linkMat); |
| link2Mesh.position.y = this.arm.l2 / 2; |
| link2Mesh.castShadow = true; |
| this.link2.add(link2Mesh); |
| this.elbowJoint.add(this.link2); |
|
|
| |
| this.wristJoint = new THREE.Group(); |
| this.wristJoint.position.y = this.arm.l2; |
| const j3Sphere = new THREE.Mesh(new THREE.SphereGeometry(0.12, 24, 24), jointMat); |
| j3Sphere.castShadow = true; |
| this.wristJoint.add(j3Sphere); |
|
|
| |
| const toolGeo = new THREE.BoxGeometry(0.35, 0.15, 0.08); |
| const toolMat = new THREE.MeshStandardMaterial({ |
| color: 0x9d4edd, |
| emissive: 0x9d4edd, |
| emissiveIntensity: 0.4, |
| metalness: 0.7 |
| }); |
| const toolMesh = new THREE.Mesh(toolGeo, toolMat); |
| toolMesh.position.y = this.arm.l3; |
| toolMesh.castShadow = true; |
| this.wristJoint.add(toolMesh); |
|
|
| this.link2.add(this.wristJoint); |
|
|
| this.scene.add(this.armGroup); |
| } |
|
|
| createInteractiveObjects() { |
| |
| const goalGeo = new THREE.RingGeometry(0.25, 0.32, 32); |
| const goalMat = new THREE.MeshBasicMaterial({ |
| color: 0x10b981, |
| side: THREE.DoubleSide, |
| transparent: true, |
| opacity: 0.8 |
| }); |
| this.goalMesh = new THREE.Mesh(goalGeo, goalMat); |
| this.goalMesh.rotation.x = -Math.PI / 2; |
| this.goalMesh.position.copy(this.goalPos); |
| this.scene.add(this.goalMesh); |
|
|
| |
| const beaconGeo = new THREE.CylinderGeometry(0.02, 0.02, 0.8, 16); |
| const beaconMat = new THREE.MeshBasicMaterial({ |
| color: 0x10b981, |
| transparent: true, |
| opacity: 0.3 |
| }); |
| const beacon = new THREE.Mesh(beaconGeo, beaconMat); |
| beacon.position.y = 0.4; |
| this.goalMesh.add(beacon); |
|
|
| |
| const puckGeo = new THREE.CylinderGeometry(0.2, 0.2, 0.16, 32); |
| const puckMat = new THREE.MeshStandardMaterial({ |
| color: 0xf59e0b, |
| emissive: 0xf59e0b, |
| emissiveIntensity: 0.25, |
| roughness: 0.2, |
| metalness: 0.6 |
| }); |
| this.puckMesh = new THREE.Mesh(puckGeo, puckMat); |
| this.puckMesh.position.copy(this.puckPos); |
| this.puckMesh.castShadow = true; |
| this.scene.add(this.puckMesh); |
| } |
|
|
| createTrails() { |
| |
| const trailMat1 = new THREE.LineBasicMaterial({ |
| color: 0x9d4edd, |
| transparent: true, |
| opacity: 0.8, |
| linewidth: 2 |
| }); |
| this.tipTrailGeo = new THREE.BufferGeometry(); |
| this.tipTrailLine = new THREE.Line(this.tipTrailGeo, trailMat1); |
| this.scene.add(this.tipTrailLine); |
|
|
| |
| const trailMat2 = new THREE.LineBasicMaterial({ |
| color: 0xf59e0b, |
| transparent: true, |
| opacity: 0.8, |
| linewidth: 2 |
| }); |
| this.puckTrailGeo = new THREE.BufferGeometry(); |
| this.puckTrailLine = new THREE.Line(this.puckTrailGeo, trailMat2); |
| this.scene.add(this.puckTrailLine); |
| } |
|
|
| setCameraPreset(type) { |
| if (type === "iso") { |
| this.camera.position.set(2.8, 3.2, 3.2); |
| } else if (type === "top") { |
| this.camera.position.set(0.2, 6.0, 0.01); |
| } else if (type === "side") { |
| this.camera.position.set(4.5, 1.2, 0); |
| } |
| } |
|
|
| setupEvents() { |
| |
| const dom = this.renderer.domElement; |
| let isDragging = false; |
|
|
| dom.addEventListener("pointerdown", (e) => { |
| const rect = dom.getBoundingClientRect(); |
| this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; |
| this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; |
|
|
| this.raycaster.setFromCamera(this.mouse, this.camera); |
| const intersects = this.raycaster.intersectObjects([this.puckMesh, this.goalMesh], true); |
|
|
| if (intersects.length > 0) { |
| this.controls.enabled = false; |
| isDragging = true; |
| const obj = intersects[0].object; |
| this.dragTarget = (obj === this.puckMesh) ? "puck" : "goal"; |
| } |
| }); |
|
|
| window.addEventListener("pointermove", (e) => { |
| if (!isDragging) return; |
| const rect = dom.getBoundingClientRect(); |
| this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; |
| this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; |
|
|
| this.raycaster.setFromCamera(this.mouse, this.camera); |
| const targetPoint = new THREE.Vector3(); |
| this.raycaster.ray.intersectPlane(this.plane, targetPoint); |
|
|
| if (targetPoint) { |
| |
| if (targetPoint.length() < this.tableRadius - 0.4) { |
| if (this.dragTarget === "puck") { |
| this.puckPos.set(targetPoint.x, 0.1, targetPoint.z); |
| this.puckVel.set(0, 0, 0); |
| } else if (this.dragTarget === "goal") { |
| this.goalPos.set(targetPoint.x, 0.02, targetPoint.z); |
| this.goalMesh.position.copy(this.goalPos); |
| } |
| } |
| } |
| }); |
|
|
| window.addEventListener("pointerup", () => { |
| isDragging = false; |
| this.controls.enabled = true; |
| this.dragTarget = null; |
| }); |
| } |
|
|
| onResize() { |
| const width = this.container.clientWidth; |
| const height = this.container.clientHeight; |
| this.camera.aspect = width / height; |
| this.camera.updateProjectionMatrix(); |
| this.renderer.setSize(width, height); |
| } |
|
|
| |
| |
| |
| setCheckpointStep(step) { |
| this.currentStep = step; |
| this.resetEpisode(); |
| } |
|
|
| resetEpisode() { |
| this.episodeSteps = 0; |
| this.episode++; |
| this.tipTrailPoints = []; |
| this.puckTrailPoints = []; |
|
|
| |
| const angle = (Math.random() - 0.5) * 1.2; |
| this.puckPos.set(0.6 + Math.cos(angle) * 0.4, 0.1, (Math.sin(angle) * 0.5)); |
| this.puckVel.set(0, 0, 0); |
| this.puckMesh.position.copy(this.puckPos); |
| } |
|
|
| computePolicyAction() { |
| const puck = this.puckPos; |
| const goal = this.goalPos; |
| const base = this.arm.basePos; |
|
|
| |
| const puckToGoal = new THREE.Vector3().subVectors(goal, puck).normalize(); |
| |
| |
| let desiredTipPos = new THREE.Vector3(); |
|
|
| if (this.currentStep === 0) { |
| |
| const t = performance.now() * 0.003; |
| desiredTipPos.set( |
| base.x + 1.2 + Math.sin(t * 2.1) * 0.8, |
| 0.15 + Math.abs(Math.sin(t * 3.5)) * 0.3, |
| Math.cos(t * 1.7) * 1.1 |
| ); |
| } |
| else if (this.currentStep === 10000) { |
| |
| const noise = (Math.sin(performance.now() * 0.005) * 0.3); |
| desiredTipPos.copy(puck).add(new THREE.Vector3(-0.35 + noise, 0.05, noise)); |
| } |
| else if (this.currentStep === 25000) { |
| |
| const behindPuck = puck.clone().sub(puckToGoal.clone().multiplyScalar(0.25)); |
| if (this.arm.tipPos.distanceTo(behindPuck) > 0.15) { |
| desiredTipPos.copy(behindPuck); |
| } else { |
| desiredTipPos.copy(puck).add(puckToGoal.clone().multiplyScalar(0.4)); |
| } |
| } |
| else { |
| |
| |
| |
| const distToPuck = this.arm.tipPos.distanceTo(puck); |
| const behindPuck = puck.clone().sub(puckToGoal.clone().multiplyScalar(0.22)); |
| behindPuck.y = 0.08; |
|
|
| if (distToPuck > 0.28 && this.puckPos.distanceTo(this.goalPos) > 0.15) { |
| desiredTipPos.copy(behindPuck); |
| } else { |
| desiredTipPos.copy(goal); |
| } |
| } |
|
|
| return desiredTipPos; |
| } |
|
|
| |
| solveIK(targetPos) { |
| const base = this.arm.basePos; |
| const dx = targetPos.x - base.x; |
| const dz = targetPos.z - base.z; |
| const dist = Math.sqrt(dx * dx + dz * dz); |
| |
| |
| const maxReach = this.arm.l1 + this.arm.l2 + this.arm.l3 - 0.05; |
| const targetDist = Math.min(dist, maxReach); |
| |
| |
| const targetAngle = Math.atan2(dz, dx); |
| |
| |
| const l1 = this.arm.l1; |
| const l2 = this.arm.l2 + this.arm.l3 * 0.7; |
| |
| let cosElbow = (targetDist * targetDist - l1 * l1 - l2 * l2) / (2 * l1 * l2); |
| cosElbow = Math.max(-1, Math.min(1, cosElbow)); |
| const elbowAngle = Math.acos(cosElbow); |
| |
| const k1 = l1 + l2 * cosElbow; |
| const k2 = l2 * Math.sin(elbowAngle); |
| const shoulderAngle = targetAngle - Math.atan2(k2, k1); |
|
|
| |
| const alpha = 0.12 * this.speedMultiplier; |
| this.arm.theta1 = THREE.MathUtils.lerp(this.arm.theta1, targetAngle, alpha); |
| this.arm.theta2 = THREE.MathUtils.lerp(this.arm.theta2, shoulderAngle, alpha); |
| this.arm.theta3 = THREE.MathUtils.lerp(this.arm.theta3, elbowAngle, alpha); |
|
|
| |
| this.armGroup.rotation.y = -this.arm.theta1; |
| this.link1.rotation.z = -(Math.PI / 2 - 0.2); |
| this.elbowJoint.rotation.z = this.arm.theta3 * 0.8; |
| this.wristJoint.rotation.z = -this.arm.theta3 * 0.5; |
|
|
| |
| const tipWorld = new THREE.Vector3(); |
| this.wristJoint.getWorldPosition(tipWorld); |
| this.arm.tipPos.copy(tipWorld); |
| } |
|
|
| updatePhysics() { |
| |
| const distTipPuck = this.arm.tipPos.distanceTo(this.puckPos); |
| const collideRadius = 0.28; |
|
|
| if (distTipPuck < collideRadius) { |
| const pushDir = new THREE.Vector3().subVectors(this.puckPos, this.arm.tipPos); |
| pushDir.y = 0; |
| pushDir.normalize(); |
|
|
| const force = (collideRadius - distTipPuck) * 1.8; |
| this.puckVel.add(pushDir.multiplyScalar(force)); |
| } |
|
|
| |
| this.puckPos.add(this.puckVel.clone().multiplyScalar(this.speedMultiplier)); |
| this.puckVel.multiplyScalar(0.90); |
|
|
| |
| if (this.puckPos.length() > this.tableRadius - 0.2) { |
| this.puckPos.normalize().multiplyScalar(this.tableRadius - 0.2); |
| this.puckVel.set(0, 0, 0); |
| } |
| this.puckMesh.position.copy(this.puckPos); |
|
|
| |
| this.distToPuck = this.arm.tipPos.distanceTo(this.puckPos); |
| this.distToGoal = this.puckPos.distanceTo(this.goalPos); |
|
|
| |
| |
| let stepReward = -(this.distToPuck * 0.4) - (this.distToGoal * 1.5); |
| if (this.distToGoal < 0.25) { |
| stepReward += 2.0; |
| this.successCount++; |
| } |
| this.currentReward = stepReward; |
|
|
| |
| this.tipTrailPoints.push(this.arm.tipPos.clone()); |
| this.puckTrailPoints.push(this.puckPos.clone()); |
| if (this.tipTrailPoints.length > this.maxTrail) this.tipTrailPoints.shift(); |
| if (this.puckTrailPoints.length > this.maxTrail) this.puckTrailPoints.shift(); |
|
|
| this.tipTrailGeo.setFromPoints(this.tipTrailPoints); |
| this.puckTrailGeo.setFromPoints(this.puckTrailPoints); |
|
|
| |
| this.episodeSteps++; |
| if (this.episodeSteps >= this.maxEpisodeSteps) { |
| this.resetEpisode(); |
| } |
| } |
|
|
| animate() { |
| requestAnimationFrame(this.animate); |
|
|
| if (this.isRunning) { |
| const desiredTip = this.computePolicyAction(); |
| this.solveIK(desiredTip); |
| this.updatePhysics(); |
| } |
|
|
| this.controls.update(); |
| this.renderer.render(this.scene, this.camera); |
|
|
| |
| if (this.onFrameUpdate) { |
| this.onFrameUpdate({ |
| episode: this.episode, |
| step: this.episodeSteps, |
| reward: this.currentReward, |
| distToPuck: this.distToPuck, |
| distToGoal: this.distToGoal, |
| checkpoint: this.currentStep |
| }); |
| } |
| } |
| } |
|
|
| window.PusherSimulator = PusherSimulator; |
|
|