מקרה לדוגמה – בניית Technitone.com

Sean Middleditch
Sean Middleditch
Technitone — חוויית אודיו באינטרנט.

Technitone.com הוא שילוב של WebGL, Canvas, Web Sockets, CSS3, JavaScript, Flash ו-Web Audio API החדש ב-Chrome.

המאמר הזה יעסוק בכל היבט בייצור: התוכנית, השרת, הצלילים, הרכיבים החזותיים וחלק מתהליך העבודה שאנחנו משתמשים בו כדי לתכנן אינטראקטיבי. רוב הקטעים מכילים קטעי קוד, הדגמה (דמו) והורדה. בסוף המאמר יש קישור להורדה שדרכו אפשר להוריד את כולם כקובץ ZIP אחד.

צוות ההפקה gskinner.com.

ההופעה

אנחנו בשום אופן לא מהנדסי אודיו ב-gskinner.com — אבל נתקלת באתגר ואנחנו נחשוב על תוכנית:

  • המשתמשים משרטטים גוונים על גבי רשת," בהשראת" מאת Andre's ToneMatrix
  • הטונים מחויטים לכלי נגינה שנדגמו, מערכות תופים ואפילו הקלטות משלהם של משתמשים
  • כמה משתמשים מחוברים משחקים באותה רשת בו-זמנית
  • ...או להיכנס למצב סולו כדי לחקור לבד
  • מפגשים בהזמנה מאפשרים למשתמשים לארגן להקה ולערוך פגישות מאולתרות

אנחנו מציעים למשתמשים הזדמנות להתנסות ב-Web Audio API באמצעות חלונית כלים שמחילה מסנני אודיו ואפקטים על הגוונים שלהם.

Technitone של gskinner.com

בנוסף:

  • אחסון קומפוזיציות ואפקטים של משתמשים כנתונים וסנכרון שלהם בין לקוחות
  • מספקים כמה אפשרויות צבע כדי שהם יוכלו לצייר שירים מגניבים
  • להציע גלריה כדי לאפשר לאנשים להקשיב, לאהוב או אפילו לערוך יצירות של אנשים אחרים

השתמשנו במטאפורת הרשת המוכרת, הצגנו אותה במרחב תלת-ממדי, הוספנו אפקטים של תאורה, מרקם וחלקיקים ואחסנו אותה בממשק מבוסס-CSS (או מסך מלא) גמיש ומבוסס-JS.

טיול בנסיעה

נתוני רשת, אפקט ואפקט מאוחדים ומסודרים אצל הלקוח, ולאחר מכן נשלחים לקצה העורפי המותאם אישית שלנו ב-Node.js כדי לפתור את הבעיה עבור משתמשים מרובים ב-Socket.io. הנתונים האלה נשלחים חזרה אל הלקוח יחד עם התרומה של כל שחקן, לפני שהם מפוזרים עם שכבות ה-CSS היחסיות, WebGL ו-WebAudio שאחראיות על עיבוד ממשק המשתמש, הדגימות והאפקטים במהלך הפעלה של מספר משתמשים.

תקשורת בזמן אמת עם sockets מעדכנת את JavaScript בלקוח ואת JavaScript בשרת.

תרשים שרת Technitone

אנחנו משתמשים ב-Node לכל היבט של השרת. זהו שרת אינטרנט סטטי ושרת השקע שלנו הכל במקום אחד. בסופו של דבר השתמשנו ב-Express, זה שרת אינטרנט מלא שנבנה לגמרי על Node. הוא ניתן להתאמה ברמה גבוהה מאוד, ניתן להתאמה אישית ברמה גבוהה, ומטפל עבורכם בהיבטים של שרת ברמה נמוכה (בדיוק כמו ש-Apache או Windows Server היו עושים זאת). לאחר מכן, אתם, כמפתחים, צריכים להתמקד בבניית האפליקציה שלכם.

הדגמה למשתמשים מרובים (אוקיי, זה רק צילום מסך)

את ההדגמה הזו יש להפעיל משרת צמתים. מאחר שמאמר זה אינו כזה, כללנו צילום מסך שמראה איך ההדגמה נראית לאחר התקנת Node.js, הגדרת שרת האינטרנט והפעלתו באופן מקומי. בכל פעם שמשתמש חדש יבקר בהתקנת ההדגמה שלכם, תתווסף רשת חדשה וכל העבודה של כולם תהיה גלויה זה לזה.

צילום מסך של ההדגמה של Node.js

הצומת קל. בעזרת שילוב של Socket.io ובקשות POST מותאמות אישית, לא היה צורך ליצור תרחישים מורכבים לסנכרון. Socket.io מטפל בזה בשקיפות; JSON מועבר ממקום למקום.

עד כמה קל? תראה את זה.

יש לנו שרת אינטרנט שפועל עם Express ב-3 שורות של JavaScript.

//Tell  our Javascript file we want to use express.
var express = require('express');

//Create our web-server
var server = express.createServer();

//Tell express where to look for our static files.
server.use(express.static(__dirname + '/static/'));

עוד כמה הגדרות לתקשורת בזמן אמת ב-socket.io.

var io = require('socket.io').listen(server);
//Start listening for socket commands
io.sockets.on('connection', function (socket) {
    //User is connected, start listening for commands.
    socket.on('someEventFromClient', handleEvent);

});

עכשיו פשוט מתחילים להאזין לחיבורים נכנסים מדף ה-HTML.

<!-- Socket-io will serve it-self when requested from this url. -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script>

 <!-- Create our socket and connect to the server -->
 var sock = io.connect('http://localhost:8888');
 sock.on("connect", handleConnect);

 function handleConnect() {
    //Send a event to the server.
    sock.emit('someEventFromClient', 'someData');
 }
 ```

## Sound check

A big unknown was the effort entailed with using the Web Audio API. Our initial findings confirmed that [Digital Signal Processing](http://en.wikipedia.org/wiki/Digital_Signal_Processing) (DSP) is very complex, and we were likely in way over our heads. Second realization: [Chris Rogers](http://chromium.googlecode.com/svn/trunk/samples/audio/index.html) has already done the heavy lifting in the API.
Technitone isn't using any really complex math or audioholicism; this functionality is easily accessible to interested developers. We really just needed to brush up on some terminology and [read the docs](https://dvcs.w3.org/hg/audio/raw-file/tip/webaudio/specification.html). Our advice? Don't skim them. Read them. Start at the top and end at the bottom. They are peppered with diagrams and photos, and it's really cool stuff.

If this is the first you've heard of the Web Audio API, or don't know what it can do, hit up Chris Rogers' [demos](http://chromium.googlecode.com/svn/trunk/samples/audio/index.html). Looking for inspiration? You'll definitely find it there.

### Web Audio API Demo

Load in a sample (sound file)…

```js
/**
 * The XMLHttpRequest allows you to get the load
 * progress of your file download and has a responseType
 * of "arraybuffer" that the Web Audio API uses to
 * create its own AudioBufferNode.
 * Note: the 'true' parameter of request.open makes the
 * request asynchronous - this is required!
 */
var request = new XMLHttpRequest();
request.open("GET", "mySample.mp3", true);
request.responseType = "arraybuffer";
request.onprogress = onRequestProgress; // Progress callback.
request.onload = onRequestLoad; // Complete callback.
request.onerror = onRequestError; // Error callback.
request.onabort = onRequestError; // Abort callback.
request.send();

// Use this context to create nodes, route everything together, etc.
var context = new webkitAudioContext();

// Feed this AudioBuffer into your AudioBufferSourceNode:
var audioBuffer = null;

function onRequestProgress (event) {
    var progress = event.loaded / event.total;
}

function onRequestLoad (event) {
    // The 'true' parameter specifies if you want to mix the sample to mono.
    audioBuffer = context.createBuffer(request.response, true);
}

function onRequestError (event) {
    // An error occurred when trying to load the sound file.
}

...הגדרת ניתוב מודולרי...

/**
 * Generally you'll want to set up your routing like this:
 * AudioBufferSourceNode > [effect nodes] > CompressorNode > AudioContext.destination
 * Note: nodes are designed to be able to connect to multiple nodes.
 */

// The DynamicsCompressorNode makes the loud parts
// of the sound quieter and quiet parts louder.
var compressorNode = context.createDynamicsCompressor();
compressorNode.connect(context.destination);

// [other effect nodes]

// Create and route the AudioBufferSourceNode when you want to play the sample.

...להחיל אפקט של זמן ריצה (קיבולה באמצעות תגובת דחף)...

/**
 * Your routing now looks like this:
 * AudioBufferSourceNode > ConvolverNode > CompressorNode > AudioContext.destination
 */

var convolverNode = context.createConvolver();
convolverNode.connect(compressorNode);
convolverNode.buffer = impulseResponseAudioBuffer;

...החלת אפקט נוסף של זמן ריצה (השהיה)...

/**
 * The delay effect needs some special routing.
 * Unlike most effects, this one takes the sound data out
 * of the flow, reinserts it after a specified time (while
 * looping it back into itself for another iteration).
 * You should add an AudioGainNode to quieten the
 * delayed sound...just so things don't get crazy :)
 *
 * Your routing now looks like this:
 * AudioBufferSourceNode -> ConvolverNode > CompressorNode > AudioContext.destination
 *                       |  ^
 *                       |  |___________________________
 *                       |  v                          |
 *                       -> DelayNode > AudioGainNode _|
 */

var delayGainNode = context.createGainNode();
delayGainNode.gain.value = 0.7; // Quieten the feedback a bit.
delayGainNode.connect(convolverNode);

var delayNode = context.createDelayNode();
delayNode.delayTime = 0.5; // Re-sound every 0.5 seconds.
delayNode.connect(delayGainNode);

delayGainNode.connect(delayNode); // make the loop

...ואז להשמיע אותו.

/**
 * Once your routing is set up properly, playing a sound
 * is easy-shmeezy. All you need to do is create an
 * AudioSourceBufferNode, route it, and tell it what time
 * (in seconds relative to the currentTime attribute of
 * the AudioContext) it needs to play the sound.
 *
 * 0 == now!
 * 1 == one second from now.
 * etc...
 */

var sourceNode = context.createBufferSource();
sourceNode.connect(convolverNode);
sourceNode.connect(delayNode);
sourceNode.buffer = audioBuffer;
sourceNode.noteOn(0); // play now!

הגישה שלנו לגבי הפעלה ב-Technitone היא עניין של תזמון. במקום לקבוע מרווח בטיימר ששווה לטמפו שלנו לעיבוד צלילים בכל פעימה, אנחנו מגדירים מרווח קטן יותר לניהול ולתזמון הצלילים בתור. כך ה-API יכול לבצע את העבודה הראשונית שצריך לבצע כדי לפתור נתוני אודיו ומסנני עיבוד ואפקטים לפני שאנחנו מקצים למעבד (CPU) את תוכן השמע. כשהקצב מגיע סוף סוף, לדוברים כבר יש את כל המידע שצריך כדי להציג את התוצאה נטו לדוברים.

באופן כללי, כל מה שהיה צריך כדי לבצע אופטימיזציה. כשדחסנו את המעבדים (CPU), דילגנו על תהליכים (הקפצה, לחיצה, גירוד) כדי לעמוד בלוח הזמנים. השקענו מאמצים רבים כדי לעצור את כל הטירוף אם תדלגו לכרטיסייה אחרת ב-Chrome.

מופע אורות

בחזית ובמרכז נמצאת רשת מנהרת החלקיקים שלנו. זוהי שכבת WebGL של Technitone.

WebGL מציע ביצועים טובים בהרבה מרוב הגישות האחרות לעיבוד רכיבים חזותיים באינטרנט, באמצעות הקצאת ה-GPU לפעולה בשילוב עם המעבד. העלייה הזו בביצועים מלווה במחיר של פיתוח מעורב משמעותי יותר, ועקומת למידה תלולה בהרבה. עם זאת, אם אתה ממש אוהב את האינטראקטיביות באינטרנט וברצונך להגביל את הביצועים במעט ככל האפשר, WebGL מציע פתרון הדומה ל-Flash.

הדגמת WebGL

תוכן WebGL מעובד לקנבס (המצופה, הקנבס של HTML5) ומורכב מאבני הבניין העיקריות הבאות:

  • קודקודים של אובייקטים (גיאומטריה)
  • מטריצות מיקום (קואורדינטות תלת ממד)
    • תוכנות הצללה (shader) (תיאור של מראה הגיאומטריה, מקושר ישירות ל-GPU)
    • ההקשר ("קיצורי דרך" לרכיבים שה-GPU מפנה אליהם)
    • מאגרים (צינורות להעברת נתוני הקשר ל-GPU)
    • את הקוד הראשי (הלוגיקה העסקית הספציפית לתוכן האינטראקטיבי הרצוי)
    • שיטת"שרטוט" (הפעלת תוכנות הצללה וציור פיקסלים על הקנבס)

התהליך הבסיסי לעיבוד תוכן WebGL למסך נראה כך:

  1. קביעה של מטריצת הפרספקטיבה (כיוונון ההגדרות של המצלמה שמציצה אל המרחב התלת-ממדי, תוך הגדרת מישור התמונות).
  2. מגדירים את מטריצת המיקום (מצהירים על מקור בקואורדינטות התלת-ממדיות שאליהן המיקומים נמדדים ביחס).
  3. ממלאים את מאגרי הנתונים המפוצלים בנתונים (מיקום הקודקוד, צבע, מרקמים...) כדי לעבור להקשר דרך תוכנות ההצללה.
  4. מחלצים ומארגנים נתונים ממאגרי ההצללה (shader) עם תוכנות הצללה (shader) ומעבירים אותם ל-GPU.
  5. קוראים לשיטת השרטוט כדי לציין את ההקשר כדי להפעיל תוכנות הצללה, להריץ עם הנתונים ולעדכן את אזור העריכה.

נראה כך:

הגדרה של מטריצת פרספקטיבה...

// Aspect ratio (usually based off the viewport,
// as it can differ from the canvas dimensions).
var aspectRatio = canvas.width / canvas.height;

// Set up the camera view with this matrix.
mat4.perspective(45, aspectRatio, 0.1, 1000.0, pMatrix);

// Adds the camera to the shader. [context = canvas.context]
// This will give it a point to start rendering from.
context.uniformMatrix4fv(shader.pMatrixUniform, 0, pMatrix);

...הגדר את מטריצת המיקום...

// This resets the mvMatrix. This will create the origin in world space.
mat4.identity(mvMatrix);

// The mvMatrix will be moved 20 units away from the camera (z-axis).
mat4.translate(mvMatrix, [0,0,-20]);

// Sets the mvMatrix in the shader like we did with the camera matrix.
context.uniformMatrix4fv(shader.mvMatrixUniform, 0, mvMatrix);

...הגדר גיאומטריה ומראה...

// Creates a square with a gradient going from top to bottom.
// The first 3 values are the XYZ position; the last 4 are RGBA.
this.vertices = new Float32Array(28);
this.vertices.set([-2,-2, 0,    0.0, 0.0, 0.7, 1.0,
                   -2, 2, 0,    0.0, 0.4, 0.9, 1.0,
                    2, 2, 0,    0.0, 0.4, 0.9, 1.0,
                    2,-2, 0,    0.0, 0.0, 0.7, 1.0
                  ]);

// Set the order of which the vertices are drawn. Repeating values allows you
// to draw to the same vertex again, saving buffer space and connecting shapes.
this.indices = new Uint16Array(6);
this.indices.set([0,1,2, 0,2,3]);

...מלא את מאגרי הנתונים החסרים והעבר אותם להקשר...

// Create a new storage space for the buffer and assign the data in.
context.bindBuffer(context.ARRAY_BUFFER, context.createBuffer());
context.bufferData(context.ARRAY_BUFFER, this.vertices, context.STATIC_DRAW);

// Separate the buffer data into its respective attributes per vertex.
context.vertexAttribPointer(shader.vertexPositionAttribute,3,context.FLOAT,0,28,0);
context.vertexAttribPointer(shader.vertexColorAttribute,4,context.FLOAT,0,28,12);

// Create element array buffer for the index order.
context.bindBuffer(context.ELEMENT_ARRAY_BUFFER, context.createBuffer());
context.bufferData(context.ELEMENT_ARRAY_BUFFER, this.indices, context.STATIC_DRAW);

...וקוראים לשיטת השרטוט

// Draw the triangles based off the order: [0,1,2, 0,2,3].
// Draws two triangles with two shared points (a square).
context.drawElements(context.TRIANGLES, 6, context.UNSIGNED_SHORT, 0);

בכל פריים, חשוב לזכור לנקות את אזור העריכה אם לא רוצים שרכיבים חזותיים מבוססי אלפא יקובצו אחד בשני.

The Venue

מלבד הרשת ומנהרת החלקיקים, כל אלמנט נוסף של ממשק המשתמש נבנה ב-HTML / CSS ולוגיקה אינטראקטיבית ב-JavaScript.

מההתחלה, החלטנו שהמשתמשים צריכים להיות בקשר עם תצוגת המשבצות מהר ככל האפשר. בלי מסך פתיחה, בלי הוראות, בלי מדריכים, רק צריך ללחוץ על 'קדימה'. אם הממשק נטען — דבר לא אמור להאט אותם.

לכן היינו צריכים לבדוק בקפידה איך להנחות את המשתמש בפעם הראשונה באינטראקציות שלו. כללנו רמזים עדינים, כמו שינוי של מאפיין סמן ה-CSS על סמך מיקום העכבר של המשתמש במרחב WebGL. אם הסמן נמצא מעל הרשת, אנחנו מעבירים אותו לסמן ידני (כי הוא יכול לקיים אינטראקציה על ידי הצגת גוונים). אם הוא מרחף ברווח הלבן שמסביב לרשת, אנחנו מחליפים אותו בסמן חוצה כיווני (כדי לציין שהם יכולים להסתובב או לפתוח את הרשת לשכבות).

מתכוננים למופע

פחות (מעבד מוקדם של CSS) ו-CodeKit (פיתוח אתרים על סטרואידים) מפחיתים באמת את הזמן שנדרש לתרגום קובצי עיצוב ל-HTML/CSS מדובשים. הם מאפשרים לנו לארגן, לכתוב ולבצע אופטימיזציה של CSS בדרך הרבה יותר רב-תכליתית — מינוף משתנים, שילובי תכונות (פונקציות) ואפילו מתמטיקה!

אפקטים של במה

באמצעות מעברי CSS3 ו-backbone.js יצרנו כמה אפקטים פשוטים שעוזרים להפיח חיים באפליקציה ומספקים למשתמשים תורים חזותיים שמציינים באיזה כלי הם משתמשים.

הצבעים של Technitone.

Backbone.js מאפשר לנו לתעד אירועים של שינוי צבע ולהחיל את הצבע החדש על רכיבי ה-DOM המתאימים. מעברי CSS3 המואצים של GPU טיפלו בשינויי סגנון הצבעים עם השפעה מועטה או אפסית על הביצועים.

רוב מעברי הצבעים ברכיבי הממשק נוצרו על ידי מעבר של צבעי רקע. מעל לצבע הרקע הזה, אנחנו מציבים תמונות רקע עם אזורים אסטרטגיים של שקיפות כדי לאפשר לצבע הרקע לבלוט.

HTML: הבסיס

לצורך ההדגמה היינו צריכים שלושה אזורי צבע: שני אזורי צבע שנבחרו על ידי המשתמש ואזור שלישי של צבעים מעורבים. פיתחנו את מבנה ה-DOM הפשוט ביותר שאפשר לחשוב עליו שתומך במעברי CSS3 ובמספר הבקשות הנמוך ביותר של HTTP לצורך המחשה.

<!-- Basic HTML Setup -->
<div class="illo color-mixed">
  <div class="illo color-primary"></div>
  <div class="illo color-secondary"></div>
</div>

CSS: מבנה פשוט עם סגנון

השתמשנו במיקום מוחלט כדי למקם כל אזור במיקום הנכון שלו ושינינו את המאפיין של מיקום הרקע כדי להתאים את איור הרקע בתוך כל אזור. הפעולה הזו גורמת לכל האזורים (שבכל אחד מהם יש תמונת רקע זהה) להיראות כמו רכיב אחד.

.illo {
  background: url('../img/illo.png') no-repeat;
  top:        0;
  cursor:     pointer;
}
  .illo.color-primary, .illo.color-secondary {
    position: absolute;
    height:   100%;
  }
  .illo.color-primary {
    width:                350px;
    left:                 0;
    background-position:  top left;
  }
  .illo.color-secondary {
    width:                355px;
    right:                0;
    background-position:  top right;
  }

הוחלו מעברים מואצים של GPU שמאזינים לאירועים של שינוי צבע. הארכנו את משך הזמן ושינינו את ההתאמה לצבעים עם הסיומת .colors כדי ליצור את הרושם שלקח זמן לשלב הצבעים.

/* Apply Transitions To Backgrounds */
.color-primary, .color-secondary {
  -webkit-transition: background .5s linear;
  -moz-transition:    background .5s linear;
  -ms-transition:     background .5s linear;
  -o-transition:      background .5s linear;
}

.color-mixed {
  position:           relative;
  width:              750px;
  height:             600px;
  -webkit-transition: background 1.5s cubic-bezier(.78,0,.53,1);
  -moz-transition:    background 1.5s cubic-bezier(.78,0,.53,1);
  -ms-transition:     background 1.5s cubic-bezier(.78,0,.53,1);
  -o-transition:      background 1.5s cubic-bezier(.78,0,.53,1);
}

אפשר לעיין ב-HTML5 לקבלת תמיכה נוכחית בדפדפן ושימוש מומלץ במעברים ב-CSS3.

Javascript: להפוך את זה לעובד

הקצאת צבעים באופן דינמי היא פשוטה. אנחנו מחפשים ב-DOM רכיב כלשהו עם רמת הצבעים שלנו ומגדירים את צבע הרקע בהתאם לבחירות הצבע של המשתמש. אנחנו מחילים את אפקט המעבר שלנו על כל רכיב ב-DOM על ידי הוספת מחלקה. כך נוצרת ארכיטקטורה קלה, גמישה וגמישה.

function createPotion() {

    var primaryColor = $('.picker.color-primary > li.selected').css('background-color');
    var secondaryColor = $('.picker.color-secondary > li.selected').css('background-color');
    console.log(primaryColor, secondaryColor);
    $('.illo.color-primary').css('background-color', primaryColor);
    $('.illo.color-secondary').css('background-color', secondaryColor);

    var mixedColor = mixColors (
            parseColor(primaryColor),
            parseColor(secondaryColor)
    );

    $('.color-mixed').css('background-color', mixedColor);
}

אחרי שבוחרים את הצבע הראשי והצבע המשני, אנחנו מחשבים את ערך הצבע המעורב ומקצים את הערך שמתקבל לרכיב ה-DOM המתאים.

// take our rgb(x,x,x) value and return an array of numeric values
function parseColor(value) {
    return (
            (value = value.match(/(\d+),\s*(\d+),\s*(\d+)/)))
            ? [value[1], value[2], value[3]]
            : [0,0,0];
}

// blend two rgb arrays into a single value
function mixColors(primary, secondary) {

    var r = Math.round( (primary[0] * .5) + (secondary[0] * .5) );
    var g = Math.round( (primary[1] * .5) + (secondary[1] * .5) );
    var b = Math.round( (primary[2] * .5) + (secondary[2] * .5) );

    return 'rgb('+r+', '+g+', '+b+')';
}

איור עבור ארכיטקטורת HTML/CSS: מתן אישיות של שלוש תיבות משנה של צבע

המטרה שלנו הייתה ליצור אפקט תאורה מהנה ומציאותי ששמור על שלמותו כאשר צבעים מנוגדים מוצבים באזורי צבע קרובים.

קובץ PNG של 24 סיביות מאפשר לצבע הרקע של רכיבי ה-HTML שלנו להופיע דרך האזורים השקופים של התמונה.

שקיפות תמונה

התיבות הצבעוניות יוצרות קצוות קשים היכן שהצבעים השונים נפגשים. האפקט הזה מפריע ליצירת אפקטים מציאותיים של תאורה, והוא היה אחד האתגרים הגדולים ביותר בתכנון האיור.

אזורי צבע

הפתרון היה לתכנן את האיור כך שלעולם לא יאפשר לקצוות של אזורי צבע להופיע דרך האזורים השקופים.

קצוות של אזורי צבע

תכנון ה-build היה קריטי. מפגש תכנון קצר בין המעצב, המפתח והמאייר עזר לצוות להבין איך יש לבנות את כל מה שצריך, כך שהשילוב יעבוד יחד.

עיינו בקובץ ה-Photoshop בתור דוגמה לאופן שבו מתן שמות לשכבות יכול להעביר מידע על בניית CSS.

קצוות של אזורי צבע

הדרן

עבור משתמשים ללא Chrome, קבענו יעד לזיקוק מהות של האפליקציה לתמונה סטטית אחת. צומת הרשת הפך ל-Hero, משבצות הרקע מרמזות על מטרת האפליקציה ונקודת המבט שמופיעה ברמזים של ההשתקפות לסביבה התלת-ממדית העשירה של הרשת.

שוליים של אזורי צבע.

אם מעניין אתכם לקבל מידע נוסף על Technitone, כדאי לעקוב אחרי הבלוג שלנו.

הלהקה

תודה על הקריאה, אולי ניכנס יחד איתך בקרוב!