webxr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +7 -0
- data/README.md +138 -0
- data/Rakefile +21 -0
- data/examples/ar_demo.html +238 -0
- data/examples/ar_hit_test.rb +157 -0
- data/examples/basic_vr.rb +110 -0
- data/examples/controller_input.rb +91 -0
- data/examples/hand_tracking.rb +124 -0
- data/examples/hello_webxr.html +288 -0
- data/examples/inline_demo.html +261 -0
- data/examples/server.rb +34 -0
- data/examples/vr_scene_demo.html +330 -0
- data/lib/webxr/ar/anchor.rb +83 -0
- data/lib/webxr/ar/hit_test_result.rb +54 -0
- data/lib/webxr/ar/hit_test_source.rb +34 -0
- data/lib/webxr/ar/ray.rb +90 -0
- data/lib/webxr/constants.rb +61 -0
- data/lib/webxr/core/frame.rb +155 -0
- data/lib/webxr/core/render_state.rb +47 -0
- data/lib/webxr/core/session.rb +212 -0
- data/lib/webxr/core/system.rb +122 -0
- data/lib/webxr/errors.rb +18 -0
- data/lib/webxr/events/input_source_event.rb +53 -0
- data/lib/webxr/events/reference_space_event.rb +44 -0
- data/lib/webxr/events/session_event.rb +56 -0
- data/lib/webxr/geometry/pose.rb +49 -0
- data/lib/webxr/geometry/rigid_transform.rb +73 -0
- data/lib/webxr/geometry/view.rb +68 -0
- data/lib/webxr/geometry/viewer_pose.rb +40 -0
- data/lib/webxr/geometry/viewport.rb +55 -0
- data/lib/webxr/hand/hand.rb +197 -0
- data/lib/webxr/hand/joint_pose.rb +33 -0
- data/lib/webxr/hand/joint_space.rb +74 -0
- data/lib/webxr/helpers/input_helper.rb +142 -0
- data/lib/webxr/helpers/rendering_helper.rb +94 -0
- data/lib/webxr/helpers/session_manager.rb +105 -0
- data/lib/webxr/input/gamepad.rb +115 -0
- data/lib/webxr/input/gamepad_button.rb +36 -0
- data/lib/webxr/input/input_source.rb +101 -0
- data/lib/webxr/input/input_source_array.rb +86 -0
- data/lib/webxr/js_wrapper.rb +116 -0
- data/lib/webxr/layers/layer.rb +28 -0
- data/lib/webxr/layers/webgl_binding.rb +69 -0
- data/lib/webxr/layers/webgl_layer.rb +102 -0
- data/lib/webxr/layers/webgl_sub_image.rb +59 -0
- data/lib/webxr/spaces/bounded_reference_space.rb +43 -0
- data/lib/webxr/spaces/reference_space.rb +51 -0
- data/lib/webxr/spaces/space.rb +18 -0
- data/lib/webxr/version.rb +5 -0
- data/lib/webxr.rb +73 -0
- data/webxr.gemspec +33 -0
- metadata +111 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>WebXR Ruby - Inline Demo (No VR Required)</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
background: #1a1a2e;
|
|
11
|
+
color: white;
|
|
12
|
+
font-family: system-ui, sans-serif;
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
align-items: center;
|
|
16
|
+
min-height: 100vh;
|
|
17
|
+
padding: 20px;
|
|
18
|
+
}
|
|
19
|
+
h1 { color: #e94560; margin-bottom: 10px; }
|
|
20
|
+
p { color: #aaa; margin-bottom: 20px; }
|
|
21
|
+
#xr-canvas {
|
|
22
|
+
width: 800px;
|
|
23
|
+
height: 450px;
|
|
24
|
+
max-width: 100%;
|
|
25
|
+
border: 2px solid #e94560;
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
}
|
|
28
|
+
button {
|
|
29
|
+
background: #e94560;
|
|
30
|
+
color: white;
|
|
31
|
+
border: none;
|
|
32
|
+
padding: 1rem 2rem;
|
|
33
|
+
font-size: 1.2rem;
|
|
34
|
+
border-radius: 8px;
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
margin: 1rem 0.5rem;
|
|
37
|
+
transition: background 0.3s;
|
|
38
|
+
}
|
|
39
|
+
button:hover { background: #ff6b6b; }
|
|
40
|
+
button:disabled { background: #444; cursor: not-allowed; }
|
|
41
|
+
#log {
|
|
42
|
+
margin-top: 1rem;
|
|
43
|
+
padding: 1rem;
|
|
44
|
+
background: #0f0f23;
|
|
45
|
+
border-radius: 8px;
|
|
46
|
+
text-align: left;
|
|
47
|
+
max-height: 150px;
|
|
48
|
+
overflow-y: auto;
|
|
49
|
+
font-family: monospace;
|
|
50
|
+
font-size: 0.85rem;
|
|
51
|
+
width: 800px;
|
|
52
|
+
max-width: 100%;
|
|
53
|
+
}
|
|
54
|
+
.log-entry { margin: 0.25rem 0; }
|
|
55
|
+
.info { color: #4ecdc4; }
|
|
56
|
+
.success { color: #7bed9f; }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<h1>WebXR Ruby - Inline Demo</h1>
|
|
61
|
+
<p>This demo works without VR headset or emulator (uses WebXR inline mode)</p>
|
|
62
|
+
|
|
63
|
+
<canvas id="xr-canvas"></canvas>
|
|
64
|
+
|
|
65
|
+
<div>
|
|
66
|
+
<button id="start-btn">Start Inline Session</button>
|
|
67
|
+
<button id="stop-btn" disabled>Stop Session</button>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div id="log"></div>
|
|
71
|
+
|
|
72
|
+
<script type="module">
|
|
73
|
+
const canvas = document.getElementById("xr-canvas");
|
|
74
|
+
const startBtn = document.getElementById("start-btn");
|
|
75
|
+
const stopBtn = document.getElementById("stop-btn");
|
|
76
|
+
const logDiv = document.getElementById("log");
|
|
77
|
+
|
|
78
|
+
let currentSession = null;
|
|
79
|
+
let gl = null;
|
|
80
|
+
|
|
81
|
+
function log(message, type = "") {
|
|
82
|
+
const entry = document.createElement("div");
|
|
83
|
+
entry.className = `log-entry ${type}`;
|
|
84
|
+
const time = new Date().toLocaleTimeString();
|
|
85
|
+
entry.textContent = `[${time}] ${message}`;
|
|
86
|
+
logDiv.appendChild(entry);
|
|
87
|
+
logDiv.scrollTop = logDiv.scrollHeight;
|
|
88
|
+
console.log(message);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function init() {
|
|
92
|
+
// Setup WebGL
|
|
93
|
+
gl = canvas.getContext("webgl2", { xrCompatible: true });
|
|
94
|
+
if (!gl) {
|
|
95
|
+
gl = canvas.getContext("webgl", { xrCompatible: true });
|
|
96
|
+
}
|
|
97
|
+
if (!gl) {
|
|
98
|
+
log("WebGL not supported!", "error");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
log("WebGL context created", "success");
|
|
102
|
+
|
|
103
|
+
// Check WebXR
|
|
104
|
+
if (!navigator.xr) {
|
|
105
|
+
log("WebXR not available - showing regular WebGL demo");
|
|
106
|
+
startRegularDemo();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log("WebXR API detected", "info");
|
|
111
|
+
|
|
112
|
+
// Check inline support (should always be supported)
|
|
113
|
+
try {
|
|
114
|
+
const supported = await navigator.xr.isSessionSupported("inline");
|
|
115
|
+
if (supported) {
|
|
116
|
+
log("Inline session supported", "success");
|
|
117
|
+
startBtn.disabled = false;
|
|
118
|
+
} else {
|
|
119
|
+
log("Inline session not supported - showing regular demo");
|
|
120
|
+
startRegularDemo();
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
log("Error checking support: " + e.message);
|
|
124
|
+
startRegularDemo();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function startRegularDemo() {
|
|
129
|
+
log("Running regular WebGL animation...", "info");
|
|
130
|
+
startBtn.style.display = "none";
|
|
131
|
+
stopBtn.style.display = "none";
|
|
132
|
+
|
|
133
|
+
let hue = 0;
|
|
134
|
+
function render() {
|
|
135
|
+
hue = (hue + 0.5) % 360;
|
|
136
|
+
const rgb = hslToRgb(hue / 360, 0.6, 0.4);
|
|
137
|
+
|
|
138
|
+
gl.clearColor(rgb[0], rgb[1], rgb[2], 1.0);
|
|
139
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
140
|
+
|
|
141
|
+
requestAnimationFrame(render);
|
|
142
|
+
}
|
|
143
|
+
render();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function startInlineSession() {
|
|
147
|
+
try {
|
|
148
|
+
log("Starting inline XR session...");
|
|
149
|
+
startBtn.disabled = true;
|
|
150
|
+
|
|
151
|
+
currentSession = await navigator.xr.requestSession("inline");
|
|
152
|
+
log("Inline session started!", "success");
|
|
153
|
+
|
|
154
|
+
stopBtn.disabled = false;
|
|
155
|
+
|
|
156
|
+
// Create XR-compatible layer
|
|
157
|
+
const layer = new XRWebGLLayer(currentSession, gl);
|
|
158
|
+
currentSession.updateRenderState({
|
|
159
|
+
baseLayer: layer,
|
|
160
|
+
inlineVerticalFieldOfView: Math.PI / 2 // 90 degrees
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
log(`Layer: ${layer.framebufferWidth}x${layer.framebufferHeight}`);
|
|
164
|
+
|
|
165
|
+
// Get viewer reference space
|
|
166
|
+
const refSpace = await currentSession.requestReferenceSpace("viewer");
|
|
167
|
+
log("Viewer reference space acquired", "info");
|
|
168
|
+
|
|
169
|
+
// Handle session end
|
|
170
|
+
currentSession.addEventListener("end", () => {
|
|
171
|
+
log("Session ended");
|
|
172
|
+
currentSession = null;
|
|
173
|
+
startBtn.disabled = false;
|
|
174
|
+
stopBtn.disabled = true;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Render loop
|
|
178
|
+
let frameCount = 0;
|
|
179
|
+
let hue = 0;
|
|
180
|
+
|
|
181
|
+
function onFrame(time, frame) {
|
|
182
|
+
if (!currentSession) return;
|
|
183
|
+
currentSession.requestAnimationFrame(onFrame);
|
|
184
|
+
frameCount++;
|
|
185
|
+
|
|
186
|
+
const pose = frame.getViewerPose(refSpace);
|
|
187
|
+
if (!pose) return;
|
|
188
|
+
|
|
189
|
+
// Bind framebuffer (null for inline = draw to canvas)
|
|
190
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer);
|
|
191
|
+
|
|
192
|
+
// Animated color
|
|
193
|
+
hue = (hue + 0.3) % 360;
|
|
194
|
+
const rgb = hslToRgb(hue / 360, 0.6, 0.4);
|
|
195
|
+
gl.clearColor(rgb[0], rgb[1], rgb[2], 1.0);
|
|
196
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
197
|
+
|
|
198
|
+
// Process each view
|
|
199
|
+
for (const view of pose.views) {
|
|
200
|
+
const viewport = layer.getViewport(view);
|
|
201
|
+
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
|
|
202
|
+
|
|
203
|
+
// In a real app, render 3D content using:
|
|
204
|
+
// - view.projectionMatrix
|
|
205
|
+
// - view.transform.matrix
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (frameCount % 60 === 0) {
|
|
209
|
+
log(`Frame ${frameCount} - Color: hsl(${Math.round(hue)}, 60%, 40%)`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
currentSession.requestAnimationFrame(onFrame);
|
|
214
|
+
log("Render loop started", "success");
|
|
215
|
+
|
|
216
|
+
} catch (e) {
|
|
217
|
+
log("Error: " + e.message);
|
|
218
|
+
startBtn.disabled = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function stopSession() {
|
|
223
|
+
if (currentSession) {
|
|
224
|
+
await currentSession.end();
|
|
225
|
+
log("Session stopped");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// HSL to RGB
|
|
230
|
+
function hslToRgb(h, s, l) {
|
|
231
|
+
let r, g, b;
|
|
232
|
+
if (s === 0) {
|
|
233
|
+
r = g = b = l;
|
|
234
|
+
} else {
|
|
235
|
+
const hue2rgb = (p, q, t) => {
|
|
236
|
+
if (t < 0) t += 1;
|
|
237
|
+
if (t > 1) t -= 1;
|
|
238
|
+
if (t < 1/6) return p + (q - p) * 6 * t;
|
|
239
|
+
if (t < 1/2) return q;
|
|
240
|
+
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|
241
|
+
return p;
|
|
242
|
+
};
|
|
243
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
244
|
+
const p = 2 * l - q;
|
|
245
|
+
r = hue2rgb(p, q, h + 1/3);
|
|
246
|
+
g = hue2rgb(p, q, h);
|
|
247
|
+
b = hue2rgb(p, q, h - 1/3);
|
|
248
|
+
}
|
|
249
|
+
return [r, g, b];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Event listeners
|
|
253
|
+
startBtn.addEventListener("click", startInlineSession);
|
|
254
|
+
stopBtn.addEventListener("click", stopSession);
|
|
255
|
+
|
|
256
|
+
// Initialize
|
|
257
|
+
init();
|
|
258
|
+
log("Demo initialized", "info");
|
|
259
|
+
</script>
|
|
260
|
+
</body>
|
|
261
|
+
</html>
|
data/examples/server.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "webrick"
|
|
5
|
+
|
|
6
|
+
port = ENV.fetch("PORT", 8000).to_i
|
|
7
|
+
document_root = __dir__
|
|
8
|
+
|
|
9
|
+
server = WEBrick::HTTPServer.new(
|
|
10
|
+
Port: port,
|
|
11
|
+
DocumentRoot: document_root,
|
|
12
|
+
Logger: WEBrick::Log.new($stdout, WEBrick::Log::INFO),
|
|
13
|
+
AccessLog: [[File.open(File::NULL, "w"), WEBrick::AccessLog::COMMON_LOG_FORMAT]]
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
puts "\n"
|
|
17
|
+
puts "=" * 50
|
|
18
|
+
puts " WebXR Ruby Examples Server"
|
|
19
|
+
puts "=" * 50
|
|
20
|
+
puts "\n"
|
|
21
|
+
puts " Serving files from: #{document_root}"
|
|
22
|
+
puts "\n"
|
|
23
|
+
puts " Open in browser:"
|
|
24
|
+
puts " http://localhost:#{port}/hello_webxr.html"
|
|
25
|
+
puts " http://localhost:#{port}/ar_demo.html"
|
|
26
|
+
puts "\n"
|
|
27
|
+
puts " Press Ctrl+C to stop"
|
|
28
|
+
puts "=" * 50
|
|
29
|
+
puts "\n"
|
|
30
|
+
|
|
31
|
+
trap("INT") { server.shutdown }
|
|
32
|
+
trap("TERM") { server.shutdown }
|
|
33
|
+
|
|
34
|
+
server.start
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>WebXR Ruby - 3D VR Scene Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
background: #000;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
}
|
|
13
|
+
canvas {
|
|
14
|
+
display: block;
|
|
15
|
+
}
|
|
16
|
+
#info {
|
|
17
|
+
position: absolute;
|
|
18
|
+
top: 10px;
|
|
19
|
+
left: 10px;
|
|
20
|
+
color: white;
|
|
21
|
+
font-family: system-ui, sans-serif;
|
|
22
|
+
font-size: 14px;
|
|
23
|
+
background: rgba(0,0,0,0.7);
|
|
24
|
+
padding: 15px;
|
|
25
|
+
border-radius: 8px;
|
|
26
|
+
z-index: 100;
|
|
27
|
+
max-width: 300px;
|
|
28
|
+
pointer-events: none;
|
|
29
|
+
}
|
|
30
|
+
#info h2 {
|
|
31
|
+
margin: 0 0 10px 0;
|
|
32
|
+
color: #e94560;
|
|
33
|
+
}
|
|
34
|
+
#info p {
|
|
35
|
+
margin: 5px 0;
|
|
36
|
+
color: #ccc;
|
|
37
|
+
}
|
|
38
|
+
#vr-button {
|
|
39
|
+
position: absolute;
|
|
40
|
+
bottom: 20px;
|
|
41
|
+
left: 50%;
|
|
42
|
+
transform: translateX(-50%);
|
|
43
|
+
background: #e94560;
|
|
44
|
+
color: white;
|
|
45
|
+
border: none;
|
|
46
|
+
padding: 15px 30px;
|
|
47
|
+
font-size: 18px;
|
|
48
|
+
border-radius: 8px;
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
z-index: 100;
|
|
51
|
+
}
|
|
52
|
+
#vr-button:hover { background: #ff6b6b; }
|
|
53
|
+
#vr-button:disabled { background: #444; cursor: not-allowed; }
|
|
54
|
+
#log {
|
|
55
|
+
position: absolute;
|
|
56
|
+
bottom: 80px;
|
|
57
|
+
left: 10px;
|
|
58
|
+
color: #4ecdc4;
|
|
59
|
+
font-family: monospace;
|
|
60
|
+
font-size: 12px;
|
|
61
|
+
background: rgba(0,0,0,0.7);
|
|
62
|
+
padding: 10px;
|
|
63
|
+
border-radius: 8px;
|
|
64
|
+
max-height: 150px;
|
|
65
|
+
overflow-y: auto;
|
|
66
|
+
z-index: 100;
|
|
67
|
+
width: 280px;
|
|
68
|
+
pointer-events: none;
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
71
|
+
|
|
72
|
+
<script type="importmap">
|
|
73
|
+
{
|
|
74
|
+
"imports": {
|
|
75
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
|
76
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<div id="info">
|
|
83
|
+
<h2>WebXR 3D Scene</h2>
|
|
84
|
+
<p>Drag to rotate view</p>
|
|
85
|
+
<p>Scroll to zoom</p>
|
|
86
|
+
<p>VR: Install WebXR Emulator or use VR headset</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<button id="vr-button" disabled>Enter VR</button>
|
|
90
|
+
|
|
91
|
+
<div id="log"></div>
|
|
92
|
+
|
|
93
|
+
<script type="module">
|
|
94
|
+
import * as THREE from 'three';
|
|
95
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
96
|
+
|
|
97
|
+
const logDiv = document.getElementById("log");
|
|
98
|
+
const vrButton = document.getElementById("vr-button");
|
|
99
|
+
|
|
100
|
+
function log(msg) {
|
|
101
|
+
const entry = document.createElement("div");
|
|
102
|
+
entry.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
|
103
|
+
logDiv.appendChild(entry);
|
|
104
|
+
logDiv.scrollTop = logDiv.scrollHeight;
|
|
105
|
+
console.log(msg);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Scene setup
|
|
109
|
+
const scene = new THREE.Scene();
|
|
110
|
+
|
|
111
|
+
// Starfield background
|
|
112
|
+
const starGeometry = new THREE.BufferGeometry();
|
|
113
|
+
const starCount = 2000;
|
|
114
|
+
const starPositions = new Float32Array(starCount * 3);
|
|
115
|
+
for (let i = 0; i < starCount * 3; i += 3) {
|
|
116
|
+
starPositions[i] = (Math.random() - 0.5) * 100;
|
|
117
|
+
starPositions[i + 1] = (Math.random() - 0.5) * 100;
|
|
118
|
+
starPositions[i + 2] = (Math.random() - 0.5) * 100;
|
|
119
|
+
}
|
|
120
|
+
starGeometry.setAttribute("position", new THREE.BufferAttribute(starPositions, 3));
|
|
121
|
+
const starMaterial = new THREE.PointsMaterial({ color: 0xffffff, size: 0.1 });
|
|
122
|
+
const stars = new THREE.Points(starGeometry, starMaterial);
|
|
123
|
+
scene.add(stars);
|
|
124
|
+
|
|
125
|
+
// Floor grid
|
|
126
|
+
const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x222222);
|
|
127
|
+
gridHelper.position.y = -1;
|
|
128
|
+
scene.add(gridHelper);
|
|
129
|
+
|
|
130
|
+
// Central glowing cube
|
|
131
|
+
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1);
|
|
132
|
+
const cubeMaterial = new THREE.MeshStandardMaterial({
|
|
133
|
+
color: 0xe94560,
|
|
134
|
+
emissive: 0xe94560,
|
|
135
|
+
emissiveIntensity: 0.3,
|
|
136
|
+
metalness: 0.8,
|
|
137
|
+
roughness: 0.2
|
|
138
|
+
});
|
|
139
|
+
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
|
|
140
|
+
cube.position.y = 0.5;
|
|
141
|
+
scene.add(cube);
|
|
142
|
+
|
|
143
|
+
// Floating spheres
|
|
144
|
+
const spheres = [];
|
|
145
|
+
const sphereColors = [0x4ecdc4, 0xffe66d, 0x7bed9f, 0xa55eea, 0x00d2d3];
|
|
146
|
+
for (let i = 0; i < 5; i++) {
|
|
147
|
+
const sphereGeometry = new THREE.SphereGeometry(0.3, 32, 32);
|
|
148
|
+
const sphereMaterial = new THREE.MeshStandardMaterial({
|
|
149
|
+
color: sphereColors[i],
|
|
150
|
+
emissive: sphereColors[i],
|
|
151
|
+
emissiveIntensity: 0.2,
|
|
152
|
+
metalness: 0.5,
|
|
153
|
+
roughness: 0.3
|
|
154
|
+
});
|
|
155
|
+
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
|
|
156
|
+
sphere.userData = {
|
|
157
|
+
angle: (i / 5) * Math.PI * 2,
|
|
158
|
+
radius: 2 + i * 0.3,
|
|
159
|
+
speed: 0.5 + i * 0.1,
|
|
160
|
+
yOffset: Math.sin(i) * 0.5
|
|
161
|
+
};
|
|
162
|
+
spheres.push(sphere);
|
|
163
|
+
scene.add(sphere);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Torus knot
|
|
167
|
+
const torusGeometry = new THREE.TorusKnotGeometry(0.5, 0.15, 100, 16);
|
|
168
|
+
const torusMaterial = new THREE.MeshStandardMaterial({
|
|
169
|
+
color: 0xff6b6b,
|
|
170
|
+
emissive: 0xff6b6b,
|
|
171
|
+
emissiveIntensity: 0.2,
|
|
172
|
+
metalness: 0.7,
|
|
173
|
+
roughness: 0.3
|
|
174
|
+
});
|
|
175
|
+
const torus = new THREE.Mesh(torusGeometry, torusMaterial);
|
|
176
|
+
torus.position.set(3, 1, -2);
|
|
177
|
+
scene.add(torus);
|
|
178
|
+
|
|
179
|
+
// Lights
|
|
180
|
+
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
|
|
181
|
+
scene.add(ambientLight);
|
|
182
|
+
|
|
183
|
+
const pointLight = new THREE.PointLight(0xffffff, 1, 50);
|
|
184
|
+
pointLight.position.set(5, 5, 5);
|
|
185
|
+
scene.add(pointLight);
|
|
186
|
+
|
|
187
|
+
const pointLight2 = new THREE.PointLight(0x4ecdc4, 0.5, 30);
|
|
188
|
+
pointLight2.position.set(-5, 3, -5);
|
|
189
|
+
scene.add(pointLight2);
|
|
190
|
+
|
|
191
|
+
// Camera
|
|
192
|
+
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
193
|
+
camera.position.set(0, 2, 5);
|
|
194
|
+
console.log("Camera set to:", camera.position.x, camera.position.y, camera.position.z);
|
|
195
|
+
|
|
196
|
+
// Renderer
|
|
197
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
198
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
199
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
200
|
+
document.body.appendChild(renderer.domElement);
|
|
201
|
+
|
|
202
|
+
// Orbit controls for non-VR
|
|
203
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
204
|
+
controls.enableDamping = true;
|
|
205
|
+
controls.dampingFactor = 0.05;
|
|
206
|
+
log(`Camera before: (${camera.position.x}, ${camera.position.y}, ${camera.position.z})`);
|
|
207
|
+
log("OrbitControls initialized - drag to rotate, scroll to zoom");
|
|
208
|
+
|
|
209
|
+
// Debug: log when controls are used
|
|
210
|
+
let lastLogTime = 0;
|
|
211
|
+
controls.addEventListener('change', () => {
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
if (now - lastLogTime > 1000) {
|
|
214
|
+
const pos = camera.position;
|
|
215
|
+
log(`Camera: (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})`);
|
|
216
|
+
lastLogTime = now;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Handle resize
|
|
221
|
+
window.addEventListener("resize", () => {
|
|
222
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
223
|
+
camera.updateProjectionMatrix();
|
|
224
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Animation
|
|
228
|
+
let time = 0;
|
|
229
|
+
function animate() {
|
|
230
|
+
time += 0.016;
|
|
231
|
+
|
|
232
|
+
// Rotate cube
|
|
233
|
+
cube.rotation.x += 0.01;
|
|
234
|
+
cube.rotation.y += 0.015;
|
|
235
|
+
|
|
236
|
+
// Animate spheres in orbit
|
|
237
|
+
spheres.forEach((sphere, i) => {
|
|
238
|
+
const data = sphere.userData;
|
|
239
|
+
data.angle += 0.01 * data.speed;
|
|
240
|
+
sphere.position.x = Math.cos(data.angle) * data.radius;
|
|
241
|
+
sphere.position.z = Math.sin(data.angle) * data.radius;
|
|
242
|
+
sphere.position.y = 1 + Math.sin(time * data.speed + i) * 0.5 + data.yOffset;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Rotate torus
|
|
246
|
+
torus.rotation.x += 0.005;
|
|
247
|
+
torus.rotation.y += 0.01;
|
|
248
|
+
|
|
249
|
+
// Slowly rotate stars
|
|
250
|
+
stars.rotation.y += 0.0002;
|
|
251
|
+
|
|
252
|
+
controls.update();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Render loop
|
|
256
|
+
function render() {
|
|
257
|
+
requestAnimationFrame(render);
|
|
258
|
+
animate();
|
|
259
|
+
renderer.render(scene, camera);
|
|
260
|
+
}
|
|
261
|
+
render();
|
|
262
|
+
|
|
263
|
+
log("3D scene initialized");
|
|
264
|
+
|
|
265
|
+
// WebXR VR support
|
|
266
|
+
async function checkVR() {
|
|
267
|
+
if (!navigator.xr) {
|
|
268
|
+
log("WebXR not available");
|
|
269
|
+
vrButton.textContent = "WebXR Not Available";
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const supported = await navigator.xr.isSessionSupported("immersive-vr");
|
|
275
|
+
if (supported) {
|
|
276
|
+
log("VR supported!");
|
|
277
|
+
vrButton.disabled = false;
|
|
278
|
+
vrButton.textContent = "Enter VR";
|
|
279
|
+
} else {
|
|
280
|
+
log("VR not supported (install WebXR Emulator)");
|
|
281
|
+
vrButton.textContent = "VR Not Supported";
|
|
282
|
+
}
|
|
283
|
+
} catch (e) {
|
|
284
|
+
log("VR check error: " + e.message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
vrButton.addEventListener("click", async () => {
|
|
289
|
+
if (!renderer.xr.isPresenting) {
|
|
290
|
+
try {
|
|
291
|
+
// Enable XR for VR session
|
|
292
|
+
renderer.xr.enabled = true;
|
|
293
|
+
|
|
294
|
+
const session = await navigator.xr.requestSession("immersive-vr", {
|
|
295
|
+
requiredFeatures: ["local-floor"]
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
renderer.xr.setReferenceSpaceType("local-floor");
|
|
299
|
+
await renderer.xr.setSession(session);
|
|
300
|
+
|
|
301
|
+
// Use XR animation loop
|
|
302
|
+
renderer.setAnimationLoop(() => {
|
|
303
|
+
animate();
|
|
304
|
+
renderer.render(scene, camera);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
log("Entered VR!");
|
|
308
|
+
vrButton.textContent = "Exit VR";
|
|
309
|
+
|
|
310
|
+
session.addEventListener("end", () => {
|
|
311
|
+
log("Exited VR");
|
|
312
|
+
vrButton.textContent = "Enter VR";
|
|
313
|
+
renderer.xr.enabled = false;
|
|
314
|
+
renderer.setAnimationLoop(null);
|
|
315
|
+
render(); // Resume normal render loop
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
} catch (e) {
|
|
319
|
+
log("VR Error: " + e.message);
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
renderer.xr.getSession().end();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
checkVR();
|
|
327
|
+
log("Ready - drag to rotate, scroll to zoom");
|
|
328
|
+
</script>
|
|
329
|
+
</body>
|
|
330
|
+
</html>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebXR
|
|
4
|
+
module AR
|
|
5
|
+
# XRAnchor - An anchor in the real world
|
|
6
|
+
# Anchors are used to place virtual content at fixed positions in AR
|
|
7
|
+
class Anchor < JSWrapper
|
|
8
|
+
# @param js_anchor [JS::Object] The XRAnchor JavaScript object
|
|
9
|
+
def initialize(js_anchor)
|
|
10
|
+
super(js_anchor)
|
|
11
|
+
@deleted = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get the anchor's coordinate space
|
|
15
|
+
# @return [Space]
|
|
16
|
+
def anchor_space
|
|
17
|
+
Space.new(js_prop(:anchorSpace))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Delete the anchor
|
|
21
|
+
# After deletion, the anchor cannot be used
|
|
22
|
+
# @return [void]
|
|
23
|
+
def delete
|
|
24
|
+
js_call(:delete)
|
|
25
|
+
@deleted = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if the anchor has been deleted
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def deleted?
|
|
31
|
+
@deleted
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Request persistent handle for this anchor
|
|
35
|
+
# Allows the anchor to be restored in future sessions
|
|
36
|
+
# @return [String] The persistent handle
|
|
37
|
+
def request_persistent_handle
|
|
38
|
+
promise = js_call(:requestPersistentHandle)
|
|
39
|
+
js_await(promise).to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# XRAnchorSet - A set of anchors
|
|
44
|
+
class AnchorSet < JSWrapper
|
|
45
|
+
include Enumerable
|
|
46
|
+
|
|
47
|
+
# @param js_anchor_set [JS::Object] The XRAnchorSet JavaScript object
|
|
48
|
+
def initialize(js_anchor_set)
|
|
49
|
+
super(js_anchor_set)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get the number of anchors
|
|
53
|
+
# @return [Integer]
|
|
54
|
+
def size
|
|
55
|
+
js_int(:size) || 0
|
|
56
|
+
end
|
|
57
|
+
alias length size
|
|
58
|
+
|
|
59
|
+
# Iterate over all anchors
|
|
60
|
+
# @yield [Anchor] Each anchor in the set
|
|
61
|
+
# @return [Enumerator, void]
|
|
62
|
+
def each(&block)
|
|
63
|
+
return enum_for(:each) unless block_given?
|
|
64
|
+
|
|
65
|
+
js_call(:forEach, ->(js_anchor) { yield Anchor.new(js_anchor) })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if the set is empty
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def empty?
|
|
71
|
+
size.zero?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Convert to array
|
|
75
|
+
# @return [Array<Anchor>]
|
|
76
|
+
def to_a
|
|
77
|
+
anchors = []
|
|
78
|
+
each { |anchor| anchors << anchor }
|
|
79
|
+
anchors
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|