מקרה לדוגמה – בניית 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 לא מהנדסי אודיו, אבל אם תציגו לנו אתגר, ננסה למצוא פתרון:

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

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

Technitone by gskinner.com

אנחנו גם:

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

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

טיול בנסיעה

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

תקשורת בזמן אמת עם שקעים מספקת JavaScript בצד הלקוח ו-JavaScript בצד השרת.

תרשים של שרת Technitone

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

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

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

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

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

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

בעזרת 3 שורות של JavaScript, אנחנו יכולים להפעיל שרת אינטרנט באמצעות Express.

//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 יכול לבצע את העבודה מראש של פתרון נתוני האודיו ועיבוד המסננים והאפקטים, לפני שאנחנו מטילים על המעבד את המשימה של הפקת האודיו. כשהפעימה הזו תגיע סוף סוף, היא כבר תכלול את כל המידע הדרוש כדי להציג את התוצאה הסופית לרמקולים.

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

מופע אורות

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

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

הדגמת WebGL

תוכן WebGL מנוהל על גבי לוח (באופן מילולי, לוח HTML5) והוא מורכב מאבני הבניין הבסיסיות הבאות:

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

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

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

כך זה נראה בפעולה:

מגדירים את מטריצת הפרספקטיבה…

// 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. אם הסמן נמצא מעל התא, הוא עובר לסמן יד (כי אפשר לבצע אינטראקציה באמצעות יצירת צלילים). אם הסמן מרחף מעל המרחב הריק שמסביב לרשת, הוא מוחלף בסמן כיוון בצורת צלב (כדי לציין שהמשתמשים יכולים לסובב את הרשת או לפצל אותה לשכבות).

הכנה לקראת השידור

LESS (מעבד CSS מראש) ו-CodeKit (פיתוח אינטרנט על סטרואידים) מקצרים מאוד את הזמן שנדרש כדי לתרגם קובצי עיצוב ל-HTML/CSS עם stubs. בעזרתם אפשר לארגן, לכתוב ולבצע אופטימיזציה של 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: מבנה פשוט עם סגנון

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

.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 ומאזינים לאירועי שינוי צבע. האריכו את משך הזמן ושינו את העקומה של ‎ .color-mixed כדי ליצור את הרושם שנדרש זמן כדי שהצבעים יתערבבו.

/* 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);
}

באתר HTML5please מפורטת תמיכת הדפדפנים הנוכחית והשימוש המומלץ במעברים של CSS3.

JavaScript: איך זה עובד

הקצאת צבעים באופן דינמי היא פשוטה. אנחנו מחפשים ב-DOM רכיב כלשהו עם סיווג הצבע שלנו ומגדירים את background-color על סמך הצבעים שבחר המשתמש. אנחנו מחילים את אפקט המעבר על כל רכיב ב-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.

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

Encore

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

צבע הקצוות של האזור.

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

הלהקה

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