logo

drewdevault.com

[mirror] blog and personal website of Drew DeVault git clone https://hacktivis.me/git/mirror/drewdevault.com.git

osuweb.md (19577B)


  1. ---
  2. date: 2015-06-14
  3. # vim: tw=80
  4. title: osu!web - WebGL & Web Audio
  5. layout: post
  6. tags: [games, osu]
  7. ---
  8. <script src="/js/underscore-min.js"></script>
  9. I've taken a liking to a video game called [osu!](https://osu.ppy.sh) over the
  10. past few months. It's a rhythm game where you use move your mouse to circles
  11. that appear with the beat, and click (or press a key) at the right time. It
  12. looks something like this:
  13. <iframe src="https://www.youtube.com/embed/qdaZnQQAPqQ" frameborder="0" allowfullscreen></iframe>
  14. The key of this game is that the "beatmaps" (a song plus notes to hit) are
  15. user-submitted. There are thousands of them, and the difficulty curve is very
  16. long - I've been playing for 10 months and I'm only maybe 70% of the way up the
  17. difficulty curve. It's also a competitive game, which leads to a lot more fun,
  18. where each user tries to complete maps a little bit better than everyone else
  19. can. You can see on the left in that video - this is a very good player who
  20. earned the #1 rank during this play.
  21. In my tendency to start writing code related to every game I play, I've been
  22. working on a cool project called [osu!web](http://www.drewdevault.com/osuweb).
  23. This is a Javascript project that can:
  24. * Decompress osu beatmap archives
  25. * Decode the music with Web Audio
  26. * Decode the osu! beatmap format
  27. * Play the map!
  28. In case you don't have any osz files hanging around, try out [this
  29. one](https://sr.ht/f30.osz), which is the one from the video above.
  30. ![](https://sr.ht/044.png)
  31. ## osu!web and the future
  32. This part of the blog post is for non-technical readers, mostly osu players.
  33. osu!web is pretty cool, and I want to make it even better. My current plans are
  34. just to make it a beatmap viewer, and I'm working now on achieving that goal. I
  35. have to finish sliders and add spinners, and eventually work on things like
  36. storyboards. Playing background videos is not in the cards because of
  37. limitations with HTML5 video.
  38. Eventually, I'd like to make it possible to link to a certain time in a certain
  39. map, or in a certain replay. Oh yeah, I want to make it support replays, too.
  40. If I get replays working, though, then I don't see any reason not to let players
  41. try the maps out in their web browsers, too. Keep an eye out!
  42. ## Technical Details
  43. This project is only possible thanks to a whole bunch of new web technologies
  44. that have been stabilizing in the past year or so. The source code is [on
  45. Github](https://github.com/SirCmpwn/osuweb) if you want to check it out.
  46. ### Loading beatmaps
  47. When the user [drags and
  48. drops](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/scenes/need-files.js#L8-L41)
  49. an osz file, we use [zip.js](https://github.com/gildas-lormeau/zip.js) and
  50. create a virtual filesystem of sorts to browse through the archive. In this
  51. archive we have several things:
  52. * A number of "tracks" - osu files that define notes and such for various
  53. difficulties
  54. * The song (mp3) and optionally a video background (avi)
  55. * Assets - a background image and optionally a skin (like a Minecraft texture
  56. pack)
  57. ![](https://sr.ht/ce6.png)
  58. We then load the *.osu files and decode them. They look similar to ini files or
  59. Unix config files. Here's a snippet:
  60. [General]
  61. AudioFilename: MuryokuP - Sweet Sweet Cendrillon Drug.mp3
  62. AudioLeadIn: 1000
  63. PreviewTime: 69853
  64. # snip
  65. [Metadata]
  66. Title:Sweet Sweet Cendrillon Drug
  67. TitleUnicode:Sweet Sweet Cendrillon Drug
  68. Artist:MuryokuP
  69. ArtistUnicode:MuryokuP
  70. Creator:Smoothie
  71. Version:Cendrillon
  72. # snip
  73. [HitObjects]
  74. 104,308,1246,5,0,0:0:0:0:
  75. 68,240,1553,1,0,0:0:0:0:
  76. 68,164,1861,1,0,0:0:0:0:
  77. 104,96,2169,1,0,0:0:0:0:
  78. 172,60,2476,2,0,P|256:48|340:60,1,170,0|0,0:0|0:0,0:0:0:0:
  79. 404,104,3399,5,0,0:0:0:0:
  80. # snip
  81. This is decoded by
  82. [osu.js](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/osu.js). For
  83. some sections (like `[Metadata]`), it just puts each entry into a dict that you
  84. can pull from later. It does more for things like hit objects, and understands
  85. which of these lines is a slider versus a hit circle versus a spinner and so on.
  86. I sneakily loaded a beatmap in the background in your browser as you were
  87. reading. If you want to check it out, open up your console and play with the
  88. `track` object. Ignore all the disqus errors, they're irrelevant.
  89. ![](https://sr.ht/a81.png)
  90. ## Enter stage: Web Audio
  91. Web Audio had a bit of a rocky development cycle, what with Chrome thinking it's
  92. special and implementing a completely different standard from everyone else.
  93. Things have [settled](http://caniuse.com/#feat=audio-api) by now, and I can
  94. start playing with it 😁 Bonus: Mozilla finally added mp3 support to all
  95. platforms, including Linux (which my dev machine runs).
  96. The osz file includes an mp3, which we
  97. [extract](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/osu.js#L209-L224)
  98. into an ArrayBuffer, and
  99. [load](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/osu-audio.js)
  100. into a Web Audio context. This is super cool and totally would not have been
  101. possible even a few months ago - kudos to the teams implementing all this
  102. exciting stuff in the browsers.
  103. That's about all we're doing with Web Audio right now. I do add a gain node so
  104. that you can control the volume with your mouse wheel. In the future, we can get
  105. more creative by:
  106. * Adding support for HT/DT mods
  107. * Adding support for NC
  108. ## Enter stage: PIXI
  109. Once we've decoded the beatmap and loaded the audio, we can play it. After
  110. briefly showing the user a difficulty selection, we jump into rendering the map.
  111. For this, I've decided to use [PIXI.js](http://pixijs.com/), which gives us a
  112. really nice API to use on top of WebGL with a canvas fallback for when WebGL is
  113. not available. I was originally just using canvas, but it wasn't very
  114. performant, so I went looking for a 2D WebGL framework and found PIXI. It's
  115. pretty cool.
  116. First, we iterate over all of the hit objects on the beatmap and generate
  117. sprites for them:
  118. ```js
  119. this.populateHit = function(hit) {
  120. // Creates PIXI objects for a given hit
  121. hit.objects = [];
  122. hit.score = -1;
  123. switch (hit.type) {
  124. case "circle":
  125. self.createHitCircle(hit);
  126. break;
  127. case "slider":
  128. self.createSlider(hit);
  129. break;
  130. }
  131. }
  132. for (var i = 0; i < this.hits.length; i++) {
  133. this.populateHit(this.hits[i]); // Prepare sprites and such
  134. }
  135. ```
  136. This is all done before we start playing. We consider the timestamp in the music
  137. that the hit is scheduled for, and then we place *all* of the hit objects into
  138. an array and start the song. See code for
  139. [createHitCircle](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/scenes/playback.js#L88-L143),
  140. which puts together a bunch of sprites for each hit cirlce and sets their alpha
  141. to zero. See also
  142. [createSlider](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/scenes/playback.js#L145-L228),
  143. which is more complicated (I'll go into detail later).
  144. Each frame, we get the current time from the Web Audio layer, and we run a
  145. function that updates a list of upcoming hit objects:
  146. ```js
  147. this.updateUpcoming = function(timestamp) {
  148. // Cache the next ten seconds worth of hit objects
  149. while (current < self.hits.length
  150. && futuremost < timestamp + (10 * TIME_CONSTANT)) {
  151. var hit = self.hits[current++];
  152. for (var i = hit.objects.length - 1; i >= 0; i--) {
  153. self.game.stage.addChildAt(hit.objects[i], 2);
  154. }
  155. self.upcomingHits.push(hit);
  156. if (hit.time > futuremost) {
  157. futuremost = hit.time;
  158. }
  159. }
  160. for (var i = 0; i < self.upcomingHits.length; i++) {
  161. var hit = self.upcomingHits[i];
  162. var diff = hit.time - timestamp;
  163. var despawn = NOTE_DESPAWN;
  164. if (hit.type === "slider") {
  165. despawn -= hit.sliderTimeTotal;
  166. }
  167. if (diff < despawn) {
  168. self.upcomingHits.splice(i, 1);
  169. i--;
  170. _.each(hit.objects, function(o) {
  171. self.game.stage.removeChild(o);
  172. o.destroy();
  173. });
  174. }
  175. }
  176. }
  177. ```
  178. I adopted this pattern early on for performance reasons. During each frame's
  179. rendering step, we only have the sprites and such loaded for hit objects in the
  180. near future. This saves a lot of time. PIXI has all of these sprites loaded and
  181. draws them for us each frame. During each frame, all we have to do is update
  182. them:
  183. ```js
  184. this.updateHitObjects = function(time) {
  185. self.updateUpcoming(time);
  186. for (var i = self.upcomingHits.length - 1; i >= 0; i--) {
  187. var hit = self.upcomingHits[i];
  188. switch (hit.type) {
  189. case "circle":
  190. self.updateHitCircle(hit, time);
  191. break;
  192. case "slider":
  193. self.updateSlider(hit, time);
  194. break;
  195. case "spinner":
  196. //self.updateSpinner(hit, time); // TODO
  197. break;
  198. }
  199. }
  200. }
  201. ```
  202. This is passed in the current timestamp in the song, and based on this we are
  203. able to do some simple math to calculate how much alpha each note should have,
  204. as well as the scale of the approach circle (which tells you when to click the
  205. note):
  206. ```js
  207. this.updateHitCircle = function(hit, time) {
  208. var diff = hit.time - time;
  209. var alpha = 0;
  210. if (diff <= NOTE_APPEAR && diff > NOTE_FULL_APPEAR) {
  211. alpha = diff / NOTE_APPEAR;
  212. alpha -= 0.5; alpha = -alpha; alpha += 0.5;
  213. } else if (diff <= NOTE_FULL_APPEAR && diff > 0) {
  214. alpha = 1;
  215. } else if (diff > NOTE_DISAPPEAR && diff < 0) {
  216. alpha = diff / NOTE_DISAPPEAR;
  217. alpha -= 0.5; alpha = -alpha; alpha += 0.5;
  218. }
  219. if (diff <= NOTE_APPEAR && diff > 0) {
  220. hit.approach.scale.x = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
  221. hit.approach.scale.y = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
  222. } else {
  223. hit.approach.scale.x = hit.objects[2].scale.y = 1;
  224. }
  225. _.each(hit.objects, function(o) { o.alpha = alpha; });
  226. }
  227. ```
  228. I've left out sliders, which again are pretty complicated. We'll get to them
  229. after you look at this screenshot again:
  230. ![](https://sr.ht/044.png)
  231. All of these hit objects are having their alpha and approach circle scale
  232. adjusted each frame by the above method. Since we're basing this on the
  233. timestamp of the map, a convenient side effect is that we can pass in any time
  234. to see what the map should look like at that time.
  235. ## Curves
  236. The hardest thing so far has been rendering sliders, which are hit objects that
  237. you're meant to click and hold as you move across the "slider". They look like
  238. this:
  239. ![](https://sr.ht/c97.png)
  240. The golden circle is the area you need to keep your mouse in if you want to pass
  241. this slider. Sliders are defined as a series of curves. There are a few kinds:
  242. * Linear sliders (not curves)
  243. * Catmull sliders
  244. * Bezier sliders
  245. For now I've only done bezier sliders. I give many thanks to
  246. [opsu](https://github.com/itdelatrisu/opsu), which I learned a lot of useful
  247. stuff about sliders from. Each slider is currently generated using the
  248. now-deprecated "peppysliders" method, where the sprite is repeated along the
  249. curve several times. If you look carefully as a slider fades out, you can notice
  250. that this is the case.
  251. ![](https://sr.ht/787.png)
  252. The newer style of sliders involves rendering them with a custom shader. This
  253. should be possible with PIXI, but I haven't done any research on them yet.
  254. Again, I expect to be able to draw a lot of knowledge from reading the opsu
  255. source code.
  256. I left out the initializer for sliders earlier, because it's long and
  257. complicated. I'll include it here so you can see how this goes:
  258. ```js
  259. this.createSlider = function(hit) {
  260. var lastFrame = hit.keyframes[hit.keyframes.length - 1];
  261. var timing = track.timingPoints[0];
  262. for (var i = 1; i < track.timingPoints.length; i++) {
  263. var t = track.timingPoints[i];
  264. if (t.offset < hit.time) {
  265. break;
  266. }
  267. timing = t;
  268. }
  269. hit.sliderTime = timing.millisecondsPerBeat *
  270. (hit.pixelLength / track.difficulty.SliderMultiplier) / 100;
  271. hit.sliderTimeTotal = hit.sliderTime * hit.repeat;
  272. // TODO: Other sorts of curves besides LINEAR and BEZIER
  273. // TODO: Something other than shit peppysliders
  274. hit.curve = new LinearBezier(hit, hit.type === SLIDER_LINEAR);
  275. for (var i = 0; i < hit.curve.curve.length; i++) {
  276. var c = hit.curve.curve[i];
  277. var base = new PIXI.Sprite(Resources["hitcircle.png"]);
  278. base.anchor.x = base.anchor.y = 0.5;
  279. base.x = gfx.xoffset + c.x * gfx.width;
  280. base.y = gfx.yoffset + c.y * gfx.height;
  281. base.alpha = 0;
  282. base.tint = combos[hit.combo % combos.length];
  283. hit.objects.push(base);
  284. }
  285. self.createHitCircle({ // Far end
  286. time: hit.time,
  287. combo: hit.combo,
  288. index: -1,
  289. x: lastFrame.x,
  290. y: lastFrame.y,
  291. objects: hit.objects
  292. });
  293. self.createHitCircle(hit); // Near end
  294. // Add follow circle
  295. var follow = hit.follow =
  296. new PIXI.Sprite(Resources["sliderfollowcircle.png"]);
  297. follow.visible = false;
  298. follow.alpha = 0;
  299. follow.anchor.x = follow.anchor.y = 0.5;
  300. follow.manualAlpha = true;
  301. hit.objects.push(follow);
  302. // Add follow ball
  303. var ball = hit.ball = new PIXI.Sprite(Resources["sliderb0.png"]);
  304. ball.visible = false;
  305. ball.alpha = 0;
  306. ball.anchor.x = ball.anchor.y = 0.5;
  307. ball.tint = 0;
  308. ball.manualAlpha = true;
  309. hit.objects.push(ball);
  310. if (hit.repeat !== 1) {
  311. // Add reverse symbol
  312. var reverse = hit.reverse =
  313. new PIXI.Sprite(Resources["reversearrow.png"]);
  314. reverse.alpha = 0;
  315. reverse.anchor.x = reverse.anchor.y = 0.5;
  316. reverse.x = gfx.xoffset + lastFrame.x * gfx.width;
  317. reverse.y = gfx.yoffset + lastFrame.y * gfx.height;
  318. reverse.scale.x = reverse.scale.y = 0.8;
  319. reverse.tint = 0;
  320. // This makes the arrow point back towards the start of the slider
  321. // TODO: Make it point at the previous keyframe instead
  322. var deltaX = lastFrame.x - hit.x;
  323. var deltaY = lastFrame.y - hit.y;
  324. reverse.rotation = Math.atan2(deltaY, deltaX) + Math.PI;
  325. hit.objects.push(reverse);
  326. }
  327. if (hit.repeat > 2) {
  328. // Add another reverse symbol
  329. var reverse = hit.reverse_b
  330. = new PIXI.Sprite(Resources["reversearrow.png"]);
  331. reverse.alpha = 0;
  332. reverse.anchor.x = reverse.anchor.y = 0.5;
  333. reverse.x = gfx.xoffset + hit.x * gfx.width;
  334. reverse.y = gfx.yoffset + hit.y * gfx.height;
  335. reverse.scale.x = reverse.scale.y = 0.8;
  336. reverse.tint = 0;
  337. var deltaX = lastFrame.x - hit.x;
  338. var deltaY = lastFrame.y - hit.y;
  339. reverse.rotation = Math.atan2(deltaY, deltaX);
  340. // Only visible when it's the next end to hit:
  341. reverse.visible = false;
  342. hit.objects.push(reverse);
  343. }
  344. }
  345. ```
  346. As you can see, there are many more moving pieces here. The important part is
  347. the curve:
  348. ```js
  349. hit.curve = new LinearBezier(hit, hit.type === SLIDER_LINEAR);
  350. for (var i = 0; i < hit.curve.curve.length; i++) {
  351. var c = hit.curve.curve[i];
  352. var base = new PIXI.Sprite(Resources["hitcircle.png"]);
  353. base.anchor.x = base.anchor.y = 0.5;
  354. base.x = gfx.xoffset + c.x * gfx.width;
  355. base.y = gfx.yoffset + c.y * gfx.height;
  356. base.alpha = 0;
  357. base.tint = combos[hit.combo % combos.length];
  358. hit.objects.push(base);
  359. }
  360. ```
  361. In the [curve
  362. code](https://github.com/SirCmpwn/osuweb/tree/gh-pages/scripts/curves), a
  363. series of points along each curve are generated for us to place sprites at.
  364. These are precomputed like all other hit objects to save time during playback.
  365. However, the render updater is still quite complicated:
  366. ```js
  367. this.updateSlider = function(hit, time) {
  368. var diff = hit.time - time;
  369. var alpha = 0;
  370. if (diff <= NOTE_APPEAR && diff > NOTE_FULL_APPEAR) {
  371. // Fade in (before hit)
  372. alpha = diff / NOTE_APPEAR;
  373. alpha -= 0.5; alpha = -alpha; alpha += 0.5;
  374. hit.approach.scale.x = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
  375. hit.approach.scale.y = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
  376. } else if (diff <= NOTE_FULL_APPEAR && diff > -hit.sliderTimeTotal) {
  377. // During slide
  378. alpha = 1;
  379. } else if (diff > NOTE_DISAPPEAR - hit.sliderTimeTotal && diff < 0) {
  380. // Fade out (after slide)
  381. alpha = diff / (NOTE_DISAPPEAR - hit.sliderTimeTotal);
  382. alpha -= 0.5; alpha = -alpha; alpha += 0.5;
  383. }
  384. // Update approach circle
  385. if (diff >= 0) {
  386. hit.approach.scale.x = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
  387. hit.approach.scale.y = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
  388. } else if (diff > NOTE_DISAPPEAR - hit.sliderTimeTotal) {
  389. hit.approach.visible = false;
  390. hit.follow.visible = true;
  391. hit.follow.alpha = 1;
  392. hit.ball.visible = true;
  393. hit.ball.alpha = 1;
  394. // Update ball and follow circle
  395. var t = -diff / hit.sliderTimeTotal;
  396. var at = hit.curve.pointAt(t);
  397. var at_next = hit.curve.pointAt(t + 0.01);
  398. hit.follow.x = at.x * gfx.width + gfx.xoffset;
  399. hit.follow.y = at.y * gfx.height + gfx.yoffset;
  400. hit.ball.x = at.x * gfx.width + gfx.xoffset;
  401. hit.ball.y = at.y * gfx.height + gfx.yoffset;
  402. var deltaX = at.x - at_next.x;
  403. var deltaY = at.y - at_next.y;
  404. if (at.x !== at_next.x || at.y !== at_next.y) {
  405. hit.ball.rotation = Math.atan2(deltaY, deltaX) + Math.PI;
  406. }
  407. if (diff > -hit.sliderTimeTotal) {
  408. var index = Math.floor(t * hit.sliderTime * 60 / 1000) % 10;
  409. hit.ball.texture = Resources["sliderb" + index + ".png"];
  410. }
  411. }
  412. if (hit.reverse) {
  413. hit.reverse.scale.x = hit.reverse.scale.y = 1 + Math.abs(diff % 300) * 0.001;
  414. }
  415. if (hit.reverse_b) {
  416. hit.reverse_b.scale.x = hit.reverse_b.scale.y = 1 + Math.abs(diff % 300) * 0.001;
  417. }
  418. _.each(hit.objects, function(o) {
  419. if (_.isUndefined(o._manualAlpha)) {
  420. o.alpha = alpha;
  421. }
  422. });
  423. }
  424. ```
  425. Much of this is the same as the hit circle updater, since we have a similar hit
  426. circle at the start of the slider that needs to update in a similar fashion.
  427. However, we also have to move the rolling ball and the follow circle along the
  428. slider as the song progresses. This involves calling out to the curve code to
  429. figure out what point is (`current_time / slider_end`) along the length of the
  430. slider. We put the ball there, and we also ask for the point at (`(current_time +
  431. 0.01) / slider_end`) and make the ball rotate to face that direction.
  432. ## Conclusions
  433. That's the bulk of the work neccessary to make an osu renderer. I'll have to add
  434. spinners once I feel like the slider code is complete, and a friend is working
  435. on adding hit sounds (sound effects that play when you correctly hit a note).
  436. The biggest problem he's facing is that Web Audio has no good solution for
  437. low-latency audio playback. On my side of things, though, everything is going
  438. great. PIXI was a really good choice - it's an easy to use API and the WebGL
  439. frontend is fast as hell. osu!web plays a map with performance that compares to
  440. the performance of osu! native.
  441. <script src="/js/osu.js"></script>
  442. <script>
  443. var xhr = new XMLHttpRequest();
  444. xhr.open("GET", "/example.osu");
  445. xhr.onload = function() {
  446. window.track = new Track(xhr.responseText);
  447. window.track.decode();
  448. };
  449. xhr.send();
  450. </script>