我的音視頻技術(shù)路線

目錄如下:

index.jpg

音視頻基礎(chǔ)技術(shù)棧

? 抖音/快手等短視頻APP的風(fēng)靡盯孙,讓音視頻成為當(dāng)下最火熱的技術(shù)锋勺,越來越多的人想要進(jìn)入到這個(gè)領(lǐng)域植捎,我自己也是從圖形方向剛剛踏入這領(lǐng)域不久,音視頻方向所包含的技術(shù)棧非常復(fù)雜喉前,我自己也在一點(diǎn)一點(diǎn)慢慢鉆研没酣,這里面每一個(gè)方向都值得深入研究,而且隨著5G時(shí)代的到來卵迂,音視頻方向的應(yīng)用會(huì)更加廣泛裕便,所以希望自己能掌握更多的關(guān)于音視頻方向的技能,未來可以探索更多的音視頻玩法见咒。然后這篇博客主要是想梳理一下我自己關(guān)于音視頻這個(gè)方向的學(xué)習(xí)路線偿衰,分享出來的同時(shí)也能鼓勵(lì)自己朝著這個(gè)方向繼續(xù)深耕下去。

? 關(guān)于音視頻方向的基礎(chǔ)技能分支改览,先來看一張圖(圖片來自網(wǎng)上)

basic.jpg

采集:音視頻數(shù)據(jù)來源下翎,比如Android Camera數(shù)據(jù)采集

渲染:將采集得到的數(shù)據(jù)展示到Surface上宝当,并添加一些圖形效果

處理:對(duì)源數(shù)據(jù)的加工视事,比如添加濾鏡、特效庆揩,還有多視頻剪輯俐东、變速、轉(zhuǎn)場(chǎng)等等

編解碼:對(duì)音視頻數(shù)據(jù)進(jìn)行壓縮封裝订晌,減少數(shù)據(jù)量虏辫,方便傳輸

傳輸:對(duì)采集加工完成的數(shù)據(jù)傳輸至客戶端,比如直播推流锈拨、拉流

1. 關(guān)于音視頻數(shù)據(jù)采集(Android)

? 因?yàn)樽约褐饕菍?duì)Android Camera比較熟悉乒裆,所以我主要梳理一下這方面的知識(shí)點(diǎn),我會(huì)從最基礎(chǔ)的Android Camera API的接口以及基本流程開始梳理推励,后面進(jìn)階部分主要是結(jié)合Camera HAL高通架構(gòu)進(jìn)一步詳細(xì)梳理底層的Camera原理鹤耍,最后是我對(duì)于Camera專業(yè)視頻方向的一些探索。

1.1 Android Camera API

? Android 5.0之后Camera接口升級(jí)成API2验辞,因?yàn)镃amera API 1接口過于簡(jiǎn)單稿黄,根本體現(xiàn)不出硬件能力,用戶能控制的不多跌造,比如拿不到RAW數(shù)據(jù)杆怕,控制不了相機(jī)參數(shù)的下發(fā)等等,如下代碼看下他內(nèi)部的基本接口

? Camera API 1

try {
            mCamera = Camera.open(mCurrentCamera);
            Camera.Parameters params = mCamera.getParameters();
            List<Camera.Size> previewSizes = params.getSupportedPreviewSizes();

            Camera.Size preViewSize = previewSizes.get(previewSizes.size() > 4 ? previewSizes.size() - 4 : 0);
            params.setPreviewSize(preViewSize.width, preViewSize.height);
            mPreviewHeight = preViewSize.height;
            mPreviewWidth = preViewSize.width;
            Log.e(TAG, "preViewSize->width: " + preViewSize.width + ", preViewSize->height: " + preViewSize.height);

            params.setPictureFormat(ImageFormat.JPEG);
            params.setJpegQuality(100);
            //是否開啟閃光
            List<String> flashModes = params.getSupportedFlashModes();
            if (flashModes != null && flashModes.contains(Camera.Parameters.FLASH_MODE_OFF)) {
                params.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
            }

            mCamera.setPreviewCallback(this);
            mCamera.setDisplayOrientation(PORTRAIT_MODE);
            mCamera.setParameters(params);
            if(!Consts.SHOW_CAMERA) {
                LogUtils.log_main_step("相機(jī)開始preview");
                mCamera.startPreview();
            }

            LogUtils.logd(TAG, "camera init finish.....");
        } catch (Exception e) {
            LogUtils.loge(TAG, e.getMessage());
            e.printStackTrace();
        }

然后在CameraPreview callback里面就可以處理相機(jī)數(shù)據(jù)了

@Override
    public void onPreviewFrame(byte[] data, Camera camera) {
        Camera.CameraInfo mCameraInfo = new Camera.CameraInfo();
        //如果使用前置攝像頭壳贪,請(qǐng)注意顯示的圖像與幀圖像左右對(duì)稱陵珍,需處理坐標(biāo)
        boolean frontCamera = (mCurrentCamera == Camera.CameraInfo.CAMERA_FACING_FRONT);

        //獲取重力傳感器返回的方向
        int dir = getDirection();

        int rotate = (dir ^ 1);
        //Log.e("stRotateCamera  ", (dir ^ 1) + " rotate result");

        //在使用后置攝像頭,且傳感器方向?yàn)?或2時(shí)违施,后置攝像頭與前置orentation相反
        if (!frontCamera && dir == 0) {
            dir = 2;
        } else if (!frontCamera && dir == 2) {
            dir = 0;
        }
        dir = (dir ^ 2);
        //.....
        process(data)
    }

關(guān)于Camera2的介紹

Camera 2.0: New computing platforms for computational photography

? Camera API2對(duì)比API 1改動(dòng)非常大互纯,主要配合HAL3進(jìn)行使用,功能和接口都更加齊全磕蒲,同時(shí)使用起來也會(huì)更加復(fù)雜留潦,但如果熟悉之后只盹,也能拍攝出更加豐富的效果。下圖關(guān)于Camera API 2的幾個(gè)使用場(chǎng)景

camera_api2.png

? 然后結(jié)合具體代碼講幾個(gè)Camera2的主要接口

  1. CameraManager

關(guān)于硬件能力的統(tǒng)一封裝接口

CameraManager cameraManager = (CameraManager)mContext.getSystemService(Context.CAMERA_SERVICE);
//可以拿到所以的相機(jī)列表兔院,比如Wide殖卑,Tele,Macro坊萝,Tele2x孵稽,Tele4x等等
String[] cameraIdList = cameraManager.getCameraIdList();
//根據(jù)Camera ID拿到對(duì)應(yīng)設(shè)備支持的能力
CameraCharacteristics cameraCharacteristics =
  cameraManager.getCameraCharacteristics(cameraIdStr);
//比如用這個(gè)去判斷支持的最小Focus distance
Float focusDistance = mCharacteristics.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE);

? 現(xiàn)如今機(jī)型的攝像頭的數(shù)量越來越多,組合也越來越豐富十偶,不同的Camera負(fù)責(zé)不同的能力肛冶,比如我們需要更大的拍攝范圍會(huì)選擇Ultra Wide,比如小米一億像素的Wide扯键,還有Macro等等睦袖。

  1. CaptureDevice

我們可以在OpenCamera Callback拿到CameraDevice

String cameraIdStr = String.valueOf(mCameraId);
            cameraManager.openCamera(cameraIdStr, mCameraStateCallback, mMainHandler);
//
private CameraDevice.StateCallback mCameraStateCallback = new CameraDevice.StateCallback() {

        @Override
        public void onOpened(@NonNull CameraDevice camera) {
            synchronized (SnapCamera.this) {
                mCameraDevice = camera;
            }
            if (mStatusListener != null) {
                mStatusListener.onCameraOpened();
            }
        }

        @Override
        public void onDisconnected(@NonNull CameraDevice camera) {
            Log.w(TAG, "onDisconnected");
            // fail-safe: make sure resources get released
            release();
        }

        @Override
        public void onError(@NonNull CameraDevice camera, int error) {
            Log.e(TAG, "onError: " + error);
            // fail-safe: make sure resources get released
            release();
        }
    };
  1. CaptureRequest

? 通過CameraDevice可以創(chuàng)建CaptureRequest,類型主要有以下幾種荣刑,1-6分布用于預(yù)覽馅笙、拍照、錄制厉亏、錄制中拍照董习、ZSL、手動(dòng)爱只。

public static final int TEMPLATE_MANUAL = 6;
public static final int TEMPLATE_PREVIEW = 1; 
public static final int TEMPLATE_RECORD = 3;
public static final int TEMPLATE_STILL_CAPTURE = 2;
public static final int TEMPLATE_VIDEO_SNAPSHOT = 4;
public static final int TEMPLATE_ZERO_SHUTTER_LAG = 5;
//創(chuàng)建的代碼
mPreviewRequestBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);

//這里是更加專業(yè)一點(diǎn)的用法酬姆,可以不看寞肖,就是拿到RequestBuilder之后我們可以去下發(fā)各種TAG渔彰,如下設(shè)置AE/AWB鎖
CaptureRequestBuilder.applyAELock(request, mConfigs.isAELocked());
CaptureRequestBuilder.applyAWBLock(request, mConfigs.isAWBLocked());
  1. Surface

Surface主要是用來接相機(jī)返回的數(shù)據(jù)吏够,可以支持不同的數(shù)據(jù)類型和分辨率

//SurfaceTexture的方式,用于預(yù)覽
mSurfaceTexture = new SurfaceTexture(false);
mSurfaceTexture.setDefaultBufferSize(optimalSize.width, optimalSize.height);
mPreviewSurface = new Surface(mSurfaceTexture);
//ImageReader 的方式, Format可以是YUV训柴、RAW哑舒,DEPTH等
mPhotoImageReader = ImageReader.newInstance(size.getWidth(), size.getHeight(),
                ImageFormat.JPEG, /* maxImages */ 2);
mPhotoImageReader.setOnImageAvailableListener(mPhotoAvailableListener, mCameraHandler);
//創(chuàng)建session
List<Surface> surfaces = Arrays.asList(mPreviewSurface, mPhotoImageReader.getSurface());
mCameraDevice.createCaptureSession(surfaces, mSessionCallback, mCameraHandler);     
  1. CaptureSession

通過上面的CameraDevice創(chuàng)建Session,在Callback里面拿到Session

mCameraDevice.createCaptureSession(surfaces, mSessionCallback, mCameraHandler);
private CameraCaptureSession.StateCallback
            mSessionCallback = new CameraCaptureSession.StateCallback() {

        @Override
        public void onConfigured(@NonNull CameraCaptureSession session) {
            synchronized (SnapCamera.this) {
                if (mCameraDevice == null) {
                    Log.e(TAG, "onConfigured: CameraDevice was already closed.");
                    session.close();
                    return;
                }
                mCaptureSession = session;
            }
            startPreview();
            capture();
        }

        @Override
        public void onConfigureFailed(@NonNull CameraCaptureSession session) {
            Log.e(TAG, "sessionCb: onConfigureFailed");
        }
    };

? 后面所有的采集工作都是基于Session幻馁,關(guān)于Session機(jī)制最早是諾基亞提出來的洗鸵,后面蘋果的IOS也采取Session作為相機(jī)拍照,錄制的會(huì)話單元仗嗦。

/*發(fā)起請(qǐng)求膘滨,后續(xù)相機(jī)開始往surface輸出數(shù)據(jù),這個(gè)可以在onConfigured里面調(diào)用稀拐,如上面的代碼                  startPreview();
capture();*/
mCaptureSession.setRepeatingRequest(mPreviewRequestBuilder.build(),
                    mCaptureCallback, mCameraHandler);
mCaptureSession.capture(requestBuilder.build(), mCaptureCallback, mCameraHandler);

// 這個(gè)是針對(duì)120/240/960Fps recording
mCaptureSession.setRepeatingBurst(requestList, mCaptureCallback, mCameraHandler);
mCaptureSession.captureBurst(requestList, listener, handler);

然后還可以通過Session控制結(jié)束動(dòng)作

mCaptureSession.abortCaptures();
mCaptureSession.stopRepeating();

? 對(duì)象更加專業(yè)一點(diǎn)的系統(tǒng)相機(jī)而言火邓,在setRepeatingRequest之前可以還需要做很多工作,比如拍照要等Focus finish,還需要Apply一些列參數(shù)贡翘,比如FocusMode, Zoom, AE/F Lock, Video FPS.....

總結(jié):上面只是一個(gè)大概流程,至于每個(gè)流程你需要去控制什么可能就會(huì)更加復(fù)雜砰逻,但其實(shí)對(duì)于第三方APP鸣驱,需要控制的不多,基本就是OpenCamera -> 配置Surface -> 創(chuàng)建Session -> 設(shè)置參數(shù) -> RepeatingRequest ->ImageReader或者GLSurafce回調(diào)接數(shù)據(jù)做后續(xù)處理蝠咆∮欢可能有些更加專業(yè)一點(diǎn)的相機(jī)應(yīng)用,可能會(huì)涉及的一些專業(yè)參數(shù)的下發(fā)刚操,比如IOS, FocusDistance, Shutter Time, EV....

1.2 Camera HAL

Android Camera硬件抽象層(HAL闸翅,Hardware Abstraction Layer)主要用于把底層camera drive與硬件和位于android.hardware中的framework APIs連接起來。Camera子系統(tǒng)主要包含了camera pipeline components 的各種實(shí)現(xiàn)菊霜,而camera HAL提供了這些組件的使用接口

官網(wǎng)的系統(tǒng)架構(gòu)圖:

camera_hal.png

? 做相機(jī)開發(fā)除了第一部分講到的APP層面的相機(jī)控制坚冀,然后就是HAL層的開發(fā)了,HAL層由芯片廠商定制(比如高通等),手機(jī)廠商可以再其上增加一些內(nèi)容鉴逞。Camera HAL 經(jīng)歷1-3的版本迭代记某,不同的硬件支持的Camera2程度不一樣,主要有以下等級(jí)

//每一個(gè)等級(jí)啥意思自己搜吧
INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY
INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED
INFO_SUPPORTED_HARDWARE_LEVEL_FULL
INFO_SUPPORTED_HARDWARE_LEVEL_3
INFO_SUPPORTED_HARDWARE_LEVEL_EXTERNAL

想了一下這一塊比較復(fù)雜构捡,我自己也不是非常的清楚液南,而且好像第三方APP一般不會(huì)涉及,后面有機(jī)會(huì)單獨(dú)整理吧(挖坑1)

1.3 CameraX

? 如上看到的Camera API2非常復(fù)雜勾徽,為了簡(jiǎn)化流程滑凉,Google推出CameraX,借助 CameraX喘帚,開發(fā)者只需兩行代碼就能利用與預(yù)安裝的相機(jī)應(yīng)用相同的相機(jī)體驗(yàn)和功能畅姊。 CameraX Extensions 是可選插件,通過該插件吹由,您可以在支持的設(shè)備上向自己的應(yīng)用中添加人像涡匀、HDR、夜間模式和美顏等效果溉知。如下預(yù)覽和拍照的代碼:

//預(yù)覽
PreviewConfig config = new PreviewConfig.Builder().build();
Preview preview = new Preview(config);

 preview.setOnPreviewOutputUpdateListener(
     new Preview.OnPreviewOutputUpdateListener() {
         @Override
         public void onUpdated(Preview.PreviewOutput previewOutput) {
             // Your code here. For example, use previewOutput.getSurfaceTexture()
             // and post to a GL renderer.
         };
 });

 CameraX.bindToLifecycle((LifecycleOwner) this, preview);

//拍照
ImageCaptureConfig config =
new ImageCaptureConfig.Builder()
        .setTargetRotation(getWindowManager().getDefaultDisplay().getRotation())
        .build();

ImagesCapture imageCapture = new ImageCapture(config);
CameraX.bindToLifecycle((LifecycleOwner) this, imageCapture, imageAnalysis, preview);

ImageCapture.Metadata metadata = new ImageCapture.Metadata();
        metadata.isReversedHorizontal = mCameraLensFacing == LensFacing.FRONT;
imageCapture .takePicture(saveLocation, metadata, executor, OnImageSavedListener);

1.4 Camera專業(yè)視頻(進(jìn)階)

? 現(xiàn)如今手機(jī)相機(jī)功能越來越強(qiáng)大陨瘩,現(xiàn)在已經(jīng)基本可以取代卡片機(jī),手機(jī)相機(jī)已經(jīng)作為手機(jī)廠商最重要的一個(gè)賣點(diǎn)级乍,尤其是DXO刷榜的行為舌劳,之后讓廠商投入更多的研發(fā)相機(jī)上面,在可以預(yù)見的未來玫荣,手機(jī)相機(jī)發(fā)展勢(shì)必會(huì)更加激進(jìn)甚淡,后面進(jìn)一步取代部分單反也是有可能的,所以相機(jī)未來應(yīng)該更多的體現(xiàn)起作為生產(chǎn)力工具的一部分捅厂,尤其是現(xiàn)在視頻作為人們記錄生活的方式贯卦,越來越得到普及资柔。

? 如果說到視頻和相機(jī),我的想法是如何去拍攝更加專業(yè)的視頻撵割,借助手機(jī)相機(jī)強(qiáng)大的功能贿堰,能否讓一部分專業(yè)人士用他作為生產(chǎn)力工具,部分取代非常不便攜的單反設(shè)備啡彬,比如借助Camera2參數(shù)調(diào)節(jié)功能去實(shí)現(xiàn)長(zhǎng)曝光羹与、延時(shí)、流光快門等效果庶灿。

? 相機(jī)更加專業(yè)的方向纵搁,主要體現(xiàn)手機(jī)廠商的系統(tǒng)相機(jī),尤其是Android端往踢,很多硬件能力只能由手機(jī)廠商自己控制腾誉,比如系統(tǒng)相機(jī)里面的專業(yè)模式,還有就是類似大疆這種峻呕,也是自己做相機(jī)硬件妄辩,然后能夠結(jié)合硬件能力,去實(shí)現(xiàn)一些更加專業(yè)的拍攝效果山上。目前軟件這塊做的比較專業(yè)的就是Fimic Pro眼耀。

filmic.png

?

? 支持各種調(diào)參:ISO ,Exposure time佩憾,WB哮伟,EV,F(xiàn)ocus distance

? 支持平滑變焦妄帘,光焦分離

? 支持曲線調(diào)整:亮度楞黄、飽和度、陰影抡驼、Gamma曲線等

? 支持LOG格式鬼廓,還有專業(yè)的音頻采集

? 有非常多的輔助信息,比如峰值對(duì)焦致盟、曝光反饋碎税、RGB直方圖信息等

? 音視頻各種格式自定義:畫幅比例、FPS馏锡、分辨率雷蹂、音頻采樣率、編碼格式等等

?

真的是一個(gè)非常強(qiáng)大的生產(chǎn)力工具

2. 關(guān)于圖形渲染

? 音視頻第二部分肯定是圖形渲染方向啦杯道,因?yàn)橹耙恢庇凶鰣D形渲染方面的工作匪煌,也寫過自己的渲染引擎,鏈接如下

? https://github.com/LukiYLS/SimpleRenderer

以及關(guān)于這個(gè)引擎的介紹

? http://yanglusheng.com/

? 所以可以大概分享一下自己的學(xué)習(xí)過程,以及關(guān)于Android GLES相關(guān)總結(jié)

2.1 圖形學(xué)基礎(chǔ)

? 我覺圖形方面基礎(chǔ)的應(yīng)該需要掌握如下:

  1. 整個(gè)圖形矩陣變換過程

World Matrix:將場(chǎng)景中所有對(duì)象統(tǒng)一到一個(gè)坐標(biāo)系下

ViewMatrix:World Matrix 變換的相機(jī)坐標(biāo)系萎庭,根據(jù)相機(jī)的三個(gè)參數(shù)生成

ProjectionMatrix:3D世界投影的2D平面

NDC:轉(zhuǎn)換到[-1 1]

Screen Matrix:轉(zhuǎn)換的屏幕坐標(biāo)系

這部分想要理解就要自己用筆手推一遍霜医,非常管用

  1. OpenGL API

熟悉API,比如紋理貼圖方式驳规,繪制點(diǎn)線面肴敛,VAO/VBO創(chuàng)建等等

  1. 光照

首先需要理解傳統(tǒng)的Phong光照,然后要看PBR达舒,理解BRDF模型和公式推導(dǎo)

還有就是Shadow這快值朋,理解shadowmap的原理叹侄,不同的光照怎樣生成深度圖巩搏,然后shadow acne,處理邊緣鋸齒等很多細(xì)節(jié)趾代,還有陰影體這塊

  1. 模版測(cè)試/深度測(cè)試

深度測(cè)試實(shí)現(xiàn)遮擋

模版測(cè)試也是非常有用贯底,比如我有篇博客里面講到的 陰影體結(jié)合模版測(cè)試實(shí)現(xiàn)矢量緊貼地形的效果

  1. 地形

怎用利用perlin noise生成地形頂點(diǎn)數(shù)據(jù)

LOD的地形:規(guī)則四叉樹劃分(Google Earth地形),以及不規(guī)則的CLOD(自適應(yīng)三角網(wǎng))

  1. 粒子系統(tǒng)

主要就是怎么控制粒子發(fā)射器撒强,粒子加速的禽捆,粒子運(yùn)動(dòng)軌跡等等

  1. 場(chǎng)景管理

如何利用四叉樹、八叉樹管理場(chǎng)景節(jié)點(diǎn)飘哨,做視錐體裁剪

........

之前寫的渲染引擎胚想,都包含這些基本模塊,大概持續(xù)完善了半年左右芽隆,對(duì)我圖形渲染方面的提升非常大浊服,包括自己也寫過軟光柵器

render.png

總結(jié):

學(xué)習(xí)圖形學(xué)最好的方式就是造輪子造輪子造輪子,自己寫引擎胚吁,自己寫軟光柵器

學(xué)習(xí)資料分享:

https://learnopengl-cn.github.io/

https://www.scratchapixel.com/

閱讀源碼:OGRE/OSG/THREEJS

工具: unity3d processing

2.2 OpenGL/GLES

  1. EGL環(huán)境創(chuàng)建

    eglGetDisplay //獲取display信息

    eglChooseConfig //設(shè)置RGBA bit depth

    eglCreatePbufferSurface // 創(chuàng)建離屏surface, 也可以eglCreateWindowSurface

    eglCreateContext //創(chuàng)建上下文

  2. GL多線程

  • SharedContext多線程

    這個(gè)網(wǎng)上也有很多資料牙躺,eglCreateContext 的時(shí)候傳入其它線程的Context,既可以共享一些GPU Buffer腕扶,比如:MediaCodec錄制的用的Surface孽拷,和預(yù)覽去sharedContext

  • Render pass如何做同步glFenceSync

    如果用glFinish可能會(huì)在某個(gè)點(diǎn)等的時(shí)間很長(zhǎng),可以用glFenceSync做同步半抱,非常不錯(cuò)

  1. 渲染優(yōu)化

? 幀率如何優(yōu)化脓恕,涉及到很多方面,這方面游戲引擎有很多技巧窿侈,比如提前視錐體裁剪进肯,遮擋剔除等等,我這里只是簡(jiǎn)單講一下在不涉及到大場(chǎng)景的優(yōu)化有哪些

個(gè)人關(guān)于效率優(yōu)化的總結(jié):(這里有部分來自組內(nèi)大佬的總結(jié))

? 盡量在渲染之前先做視錐體裁剪工作棉磨,減少不必要的IO

? 盡量少使用一些同步阻塞操作江掩,比如glReadPixel、glFinish、glTeximage2D等

? Shader里面少使用if/for這種操作

? 必要時(shí)做下采樣

? 利用好內(nèi)存對(duì)齊环形,會(huì)有意想不到的效率提升

? 渲染之前做好一些列準(zhǔn)備工作策泣,比如編譯Shader

2.3 音視頻渲染引擎(扒抖音)

? 現(xiàn)在短視頻應(yīng)用關(guān)于特效部分底層都有會(huì)有一套渲染引擎,不管的抖音還是快手抬吟,你把抖音的的APP package pull出來就能看到萨咕,里面是同lua腳本去調(diào)用底層的渲染引擎,lua腳本負(fù)責(zé)下發(fā)一些參數(shù)火本,主要是AI的一些識(shí)別結(jié)果以及用戶的交互事件危队。

? 雖然沒有游戲引擎那么強(qiáng)大,但基本模塊應(yīng)該都會(huì)包含模型钙畔、資源管理茫陆、相機(jī)控制、裁剪擎析、渲染等等簿盅,畢竟寫這套引擎的基本都是以前搞游戲的那撥人,算是降維來寫音視頻渲染引擎揍魂。

? 下面通過拆解抖音內(nèi)部的資源文件桨醋,探究?jī)?nèi)部關(guān)于音視頻渲染引擎的模塊組成,對(duì)比短視頻引擎和游戲引擎的區(qū)別现斋。

一起來看看抖音最近很火的一個(gè)游戲:潛水艇喜最,通過移動(dòng)鼻子控制潛水艇

tiktok_demo.png

如下是download 的資源包

tiktok_effect1.png

? 這里面的核心控制邏輯就是那個(gè)lua基本,里面會(huì)負(fù)責(zé)把人臉關(guān)鍵點(diǎn)信息傳給底層引擎庄蹋,實(shí)時(shí)更新潛水艇的位置瞬内,腳本實(shí)現(xiàn)了兩個(gè)碰撞檢測(cè)函數(shù),用于潛水艇和柱子之間做碰撞檢測(cè)蔓肯。Collision玩過游戲引擎的都很熟悉遂鹊,有些游戲引擎Collision detect做的不好的會(huì)出現(xiàn)穿模的現(xiàn)象。

? rectCollision: 潛水艇中心坐標(biāo)和柱子底部坐標(biāo)之間的距離蔗包,與潛水艇半徑比較秉扑,小于0.9*R,就是碰撞了

? circleCollision: 兩個(gè)圓心坐標(biāo)之間的距離和他們各自半徑之和比較调限,小于半徑和舟陆,就是碰撞了

local intpx = 220      --柱子左右間隔
local intpy = 550      --柱子中間縫隙寬度
local range_y = {0.3, 0.7}      --柱子縫隙中央隨機(jī)范圍,{0.3,0.7}代表縫隙可能在屏幕高度30%-70%的位置隨機(jī)出現(xiàn)
local sp = {0.5,1.0}         --速度初始和最終速度耻矮,{0.5,1.0}代表初始速度為1秒走過0.5個(gè)屏幕秦躯,最終速度為1秒走過1個(gè)屏幕
local ptime = 3           --準(zhǔn)備時(shí)間3s
local range_s = {5,10}  --分?jǐn)?shù)分段,{5,10}代表0-5第一段裆装,6-10第二段踱承,10以上第三段倡缠;10以上未碰撞第四段
local smul = 0.8        --字號(hào)倍率
  
  //.....此次省略N行
 
//兩個(gè)碰撞檢測(cè)函數(shù)
//u=1,2 代碼上面和下面柱子
local function rectCollision(i,u)
    local K_s_x = sub.x
    local K_s_y = sub.y * ratio
    local K_rect_x = pillar[i].x
    local K_rect_y = 0

    
    local l = K_rect_x - r 
    local r = K_rect_x + r 
    local t = 0
    local b = 0
    if u == 1 then
        t = -1
        b = (pillar[i].y - interval_y / 2) * ratio
    else
        t = (pillar[i].y + interval_y / 2) * ratio
        b = 2
    end

    local closestP_x = 0.0
    local closestP_y = 0.0

    closestP_x = clamp(K_s_x, l , r)
    closestP_y = clamp(K_s_y, t , b)
    local dist = distance(closestP_x, closestP_y, K_s_x, K_s_y)
    if dist <= sub.r * 0.9 then 
        return true
    else
        return false
    end
    return false
end

local function circleCollision(i,u)
    local K_s_x = sub.x
    local K_s_y = sub.y * ratio
    local K_cir_x = pillar[i].x
    local K_cir_y = 0

    if u == 1 then
        K_cir_y = (pillar[i].y - interval_y / 2) * ratio
    else
        K_cir_y = (pillar[i].y + interval_y / 2) * ratio
    end
    local dist = distance(K_cir_x, K_cir_y, K_s_x, K_s_y)
    if dist <= sub.r * 0.9 + r then 
        return true
    else
        return false
    end
    return false
end

還有兩個(gè)重要的函數(shù),分別處理預(yù)覽和錄制茎活,游戲過程的邏輯昙沦,比如柱子隨著時(shí)間線一直在移動(dòng),speed在加快

handleTimerEvent = function(this, timerId, milliSeconds)
        if timerId == timer_ID_Fast and gaming then
            if init_state ~= 0 then
                return true
            end

            timeThis = getTime(this)
            timeDelta = getDiffTime(timeLast, timeThis)
            timeCumu = timeCumu + timeDelta
            timeLast = timeThis
            
            local speed = sp[1] + clamp(timeCumu / 14, 0,1) * (sp[2] - sp[1])

            if timeCumu > 14 then
                gaming = false
                Sticker2DV3.playClip(this, feature_0.folder, feature_0.entity[1], feature_0.clip[1], 0)
                
                CommonFunc.setFeatureEnabled(this, feature_t.folder, true)
                if score >= 10 then
                    realTimeFunc.initRealTime(this, feature_t.folder, true, feature_t.realTimeParams[1])
                else
                    realTimeFunc.initRealTime(this, feature_t.folder, true, feature_t.realTimeParams[2])
                end
                realTimeFunc.setRealTime(this, feature_t.folder, "u_data", score)
                
            end

            for i = 1,5 do
                pillar[i].x = pillar[i].x - speed * timeDelta
                if pillar[i].x < -r then
                    pillar[i].x = pillar[i].x + interval_x * 5
                    pillar[i].y = range_y[1] + math.random() * (range_y[2] - range_y[1])
                    pillar[i].counted = false
                end
                if pillar[i].x < sub.x and not pillar[i].counted then
                    pillar[i].counted = true
                    score = score + 1
                    
                end

                local cy = pillar[i].y - (interval_y / 2 - r / ratio) - 0.5
                local cx = pillar[i].x
                set4Vtx(this, feature_3.folder, feature_3.entity[1], feature_3.clip[2*i-1], cx , cy, 2 * r, 1.0 , 0.0, 1.0, ratio, 0.0, 0.0)

                cy = pillar[i].y + (interval_y / 2 - r / ratio) + 0.5
                //更新實(shí)體坐標(biāo)
                set4Vtx(this, feature_3.folder, feature_3.entity[1], feature_3.clip[2*i], cx , cy, 2 * r, 1.0 , 0.0, 1.0, ratio, 0.0, 0.0)
            end

            if noseY ~= 0 then
                sub.y = noseY
            end
            if (sub.y ~= 0 or lastY ~= 0) then
                sub.a = sub.a * 0.8 + (sub.y - lastY) / timeDelta * 0.2
            else
                sub.a = sub.a * 0.8
            end

            set4Vtx(this, feature_2.folder, feature_2.entity[1], feature_2.clip[1], sub.x , sub.y, 2 * sub.r, 2 * sub.r / ratio / 1.1 , sub.a, 1.0, ratio, 0.0, 0.0)

                        //對(duì)所有柱子遍歷做碰撞檢測(cè)
            for i = 1,5 do
                if rectCollision(i,1) or rectCollision(i,2) or circleCollision(i,1) or circleCollision(i,2) then

                    gaming = false
                    if score <= range_s[1] then
                        Sticker2DV3.playClip(this, feature_0.folder, feature_0.entity[1], feature_0.clip[4], 0)
                    elseif score <= range_s[2] then
                        Sticker2DV3.playClip(this, feature_0.folder, feature_0.entity[1], feature_0.clip[3], 0)
                    else
                        Sticker2DV3.playClip(this, feature_0.folder, feature_0.entity[1], feature_0.clip[2], 0)
                    end

                    CommonFunc.setFeatureEnabled(this, feature_t.folder, true)
                    if score >= 10 then
                        realTimeFunc.initRealTime(this, feature_t.folder, true, feature_t.realTimeParams[1])
                    else
                        realTimeFunc.initRealTime(this, feature_t.folder, true, feature_t.realTimeParams[2])
                    end
                    realTimeFunc.setRealTime(this, feature_t.folder, "u_data", score)
                end

            end
            
            lastY = sub.y
        end

        return true
    end,


    handleRecodeVedioEvent = function (this, eventCode)
        if (init_state ~= 0) then
            return true
        end
        if (eventCode == 1) then
            timeLast = getTime(this)
            timeCumu = 0

            score = 0
            gaming = true
            pillar = 
            {
                {
                    x = start_x,
                    y = range_y[1] + math.random() * (range_y[2] - range_y[1]),
                    counted = false
                },
                {
                    x = start_x + interval_x,
                    y = range_y[1] + math.random() * (range_y[2] - range_y[1]),
                    counted = false
                },
                {
                    x = start_x + interval_x * 2,
                    y = range_y[1] + math.random() * (range_y[2] - range_y[1]),
                    counted = false
                },
                {
                    x = start_x + interval_x * 3,
                    y = range_y[1] + math.random() * (range_y[2] - range_y[1]),
                    counted = false
                },
                {
                    x = start_x + interval_x * 4,
                    y = range_y[1] + math.random() * (range_y[2] - range_y[1]),
                    counted = false
                },
            }

            sub = 
            {
                r = 0.105,
                x = 0.24,
                y = 0.5,
                sy = 0.0,
                a = 0
            }
            lastY = noseY
            for i = 1,10 do
                Sticker2DV3.playClip(this, feature_3.folder, feature_3.entity[1], feature_3.clip[i], 0)
                set4Vtx(this, feature_3.folder, feature_3.entity[1], feature_3.clip[i], -2 , -2, 0, 0 , 0.0, 0.0, ratio, 0.0, 0.0)
            end
            for i = 1,4 do
                Sticker2DV3.stopClip(this, feature_0.folder, feature_0.entity[1], feature_0.clip[i])
            end
            set4Vtx(this, feature_2.folder, feature_2.entity[1], feature_2.clip[1], -2 , -2, 0, 0 , 0, 0, ratio, 0.0, 0.0)
            CommonFunc.setFeatureEnabled(this, feature_t.folder, false)
        end
        return true
    end,
    }

? 然后有幾個(gè)stcker文件载荔,可以看到有一個(gè)是柱子的entity信息盾饮,有一個(gè)是潛水艇的entity信息,后面那個(gè)游戲失敗時(shí)彈出的那個(gè)框框模型懒熙。然后每一個(gè)stcker文件夾都有兩個(gè)json文件丘损,其中主要是clip.json,里面主要包含實(shí)體的基本信息工扎,還有transform參數(shù)徘钥。

"clipname1": {
        "alphaFactor": 1.0,
        "blendmode": 0,
        "fps": 16,
        "height": 801,
        "textureIdx": {
          "idx": [
            0
          ],
          "type": "image"
        },
        "transformParams": {
          "position": {
            "point0": {
              "anchor": [
                0.0,
                0.5
              ],
              "point": [
                {
                  "idx": "topright",
                  "relationRef": 0,
                  "relationType": "foreground",
                  "weight": 0.5635420937542709
                },
                {
                  "idx": "bottomleft",
                  "relationRef": 0,
                  "relationType": "foreground",
                  "weight": -0.0793309886223863
                }
              ]
            },
            "point1": {
              "anchor": [
                1.0,
                0.5
              ],
              "point": [
                {
                  "idx": "topright",
                  "relationRef": 0,
                  "relationType": "foreground",
                  "weight": 0.7854868849874516
                },
                {
                  "idx": "bottomleft",
                  "relationRef": 0,
                  "relationType": "foreground",
                  "weight": -0.0793309886223863
                }
              ]
            }
          },
          "relation": {
            "foreground": 1
          },
          "relationIndex": [
            0
          ],
          "relationRefOrder": 0,
          "rotationtype": 1,
          "scale": {
            "scaleY": {
              "factor": 1.0
            }
          }
        },
        
        ....

總結(jié):

? 從effect資源可以看出,抖音底層有一個(gè)類似游戲引擎的這樣的渲染框架定庵,然后通過腳本進(jìn)行控制吏饿。功能也應(yīng)該是挺齊全的踪危,可能是縮小版的游戲引擎蔬浙,就是麻雀雖小,五臟俱全贞远。

? 對(duì)于熟悉游戲引擎的人來說畴博,這部分應(yīng)該不難,基本都是那些

? 另外蓝仲,如果想學(xué)習(xí)引擎這快俱病,我的思路是多去看看一些流行的開源引擎,一開始模仿別人怎么寫袱结,然后不斷的思考總結(jié)亮隙,慢慢的你就知道一個(gè)引擎應(yīng)該包含哪些,以及怎么控制各個(gè)模塊之間的交互垢夹。

? 比如我之前深入研究過OGRE溢吻,看過OSG,以及深入研究過THREEJS果元,同時(shí)我自己寫的渲染引擎促王,有把這幾個(gè)引擎比較好的模塊模仿過來,逐漸內(nèi)化成自己的引擎而晒。

? 后面有機(jī)會(huì)把拆解一下抖音比較復(fù)雜的特效吧蝇狼,最好找一個(gè)和AI或者AR結(jié)合的特效(挖坑2)

3. 關(guān)于音視頻圖形部分

? 這里主要會(huì)大概整理一下比較基礎(chǔ)的幾個(gè)部分,包括濾鏡倡怎、美顏迅耘、特效贱枣、轉(zhuǎn)場(chǎng)這些,因?yàn)槲夷壳昂孟裰蛔鲞^這些基礎(chǔ)的玩法颤专,更加復(fù)雜的就涉及到渲染引擎冯事,還有AI圖像方面,后面會(huì)繼續(xù)學(xué)習(xí)研究

3.1 濾鏡篇

現(xiàn)在的濾鏡主要還是查找表的形式包括1D/3D LUT血公,可能有些也會(huì)用AI去做昵仅,比如風(fēng)格化遷移等

1D LUT:

調(diào)節(jié)亮度,對(duì)比度累魔,黑白等級(jí)256 x 1摔笤,只影響Gamma曲線

RGB曲線調(diào)節(jié)256 x 3

Instagram 里面有很多1D的LUT應(yīng)用,比如amaro, lomo, Hudson, Sierra.....

void main() {

    vec2 uv = gl_FragCoord.st/u_resolution;
    uv.y = 1.0 - uv.y;

    vec4 originColor = texture2D(u_texture, uv);
    vec4 texel = texture2D(u_texture, uv);
    vec3 bbTexel = texture2D(u_blowoutTex, uv).rgb;
        //256x1
    texel.r = texture2D(u_overlayTex, vec2(bbTexel.r, texel.r)).r;
    texel.g = texture2D(u_overlayTex, vec2(bbTexel.g, texel.g)).g;
    texel.b = clamp(texture2D(u_overlayTex, vec2(bbTexel.b, texel.b)).b, 0.1, 0.9);
        //256x3
    vec4 mapped;
    mapped.r = texture2D(u_mapTex, vec2(texel.r, .25)).r;
    mapped.g = texture2D(u_mapTex, vec2(texel.g, .5)).g;
    mapped.b = texture2D(u_mapTex, vec2(texel.b, 0.1)).b;
    mapped.a = 1.0;

    mapped.rgb = mix(originColor.rgb, mapped.rgb, 1.0);

    gl_FragColor = mapped;


}

Amaro 風(fēng)格

amaro.png

Lomo 風(fēng)格

lomo.png

......

3D LUT:

Lookup table : 64x64 512x512

//這沒啥說的垦写,都是很基本的
void main() {

    vec2 uv = gl_FragCoord.st/u_resolution;
    uv.y = 1.0 - uv.y;
    lowp vec3 textureColor = texture2D(u_texture, uv).rgb;

    textureColor = clamp((textureColor - vec3(u_levelBlack, u_levelBlack, u_levelBlack)) * u_levelRangeInv, 0.0, 1.0);
    textureColor.r = texture2D(u_grayTexture, vec2(textureColor.r, 0.5)).r;
    textureColor.g = texture2D(u_grayTexture, vec2(textureColor.g, 0.5)).g;
    textureColor.b = texture2D(u_grayTexture, vec2(textureColor.b, 0.5)).b;

    mediump float blueColor = textureColor.b * 15.0;

    mediump vec2 quad1;
    quad1.y = floor(blueColor / 4.0);
    quad1.x = floor(blueColor) - (quad1.y * 4.0);

    mediump vec2 quad2;
    quad2.y = floor(ceil(blueColor) / 4.0);
    quad2.x = ceil(blueColor) - (quad2.y * 4.0);

    highp vec2 texPos1;
    texPos1.x = (quad1.x * 0.25) + 0.5 / 64.0 + ((0.25 - 1.0 / 64.0) * textureColor.r);
    texPos1.y = (quad1.y * 0.25) + 0.5 / 64.0 + ((0.25 - 1.0 / 64.0) * textureColor.g);

    highp vec2 texPos2;
    texPos2.x = (quad2.x * 0.25) + 0.5 / 64.0 + ((0.25 - 1.0 / 64.0) * textureColor.r);
    texPos2.y = (quad2.y * 0.25) + 0.5 / 64.0 + ((0.25 - 1.0 / 64.0) * textureColor.g);

    lowp vec4 newColor1 = texture2D(u_lookupTexture, texPos1);
    lowp vec4 newColor2 = texture2D(u_lookupTexture, texPos2);

    lowp vec3 newColor = mix(newColor1.rgb, newColor2.rgb, fract(blueColor));

    textureColor = mix(textureColor, newColor, u_strength);

    gl_FragColor = vec4(textureColor, 1.0); 

}

Fairytale風(fēng)格

Fairytale.png

? 可以看到3D LUT看起來更加和諧一點(diǎn)吕世,因?yàn)?D LUT 的RGB映射關(guān)系是關(guān)聯(lián)在一起,1D LUT則是獨(dú)立的梯投,不過1D LUT可以節(jié)省空間命辖,有些只需要單通道就可以搞定的,就可以用1D LUT

? LUT得益于其簡(jiǎn)單的生產(chǎn)流程分蓖,基本設(shè)計(jì)師那邊用PhotoShop調(diào)好一張查找表尔艇,這邊就應(yīng)用上去就OK了

相關(guān)資料:

https://affinityspotlight.com/article/1d-vs-3d-luts/

https://zhuanlan.zhihu.com/p/37147849

https://zhuanlan.zhihu.com/p/60702944

3.2 美顏篇

  1. 磨皮去燥

    最簡(jiǎn)單的方式,就是利用各種降噪濾波器么鹤,去掉高頻噪聲部分

    均值模糊 權(quán)重分布平均

    高斯模糊 權(quán)重高斯分布

    表面模糊 權(quán)重分布只跟顏色空間有關(guān)系(與膚色檢測(cè)配合)

    雙邊濾波 權(quán)重分布跟距離和顏色空間分布有關(guān)系终娃,

    中值模糊 卷積核范圍內(nèi)去中值

    導(dǎo)向?yàn)V波 參考圖像

    vec4 BilateralFilter(vec2 uv) {
        float i = uv.x;
        float j = uv.y;
        float sigmaSSquare = 2.0 * SigmaS * SigmaS;
        float sigmaRSquare = 2.0 * SigmaR * SigmaR;
        vec3 centerColor = texture2D(u_texture, uv).rgb;
        float centerGray = Luminance(centerColor);
        vec3 sum_up, sum_down;
        for(int k = -u_radius; k <= u_radius; k++) {
            for(int l = -u_radius; l <= u_radius; l++) {
                vec2 uv_new = uv + vec2(k, l)/u_resolution;
                vec3 curColor = texture2D(u_texture, uv_new).rgb;
                float curGray = Luminance(curColor);
                vec3 deltaColor = curyolor - centerColor;
                float len = dot(deltaColor, deltaColor);
                float exponent = -((i-k)*(i-k)+(j-l)*(j-l))/sigmaSSquare - len/sigmaRSquare;
                float weight = exp(exponent);
                sum_up += curColor * weight;
                sum_down += weight;
            }
        }
        vec3 color = sum_up / sum_down;
        return vec4(color, 1.0);
    }
    vec4 SurfaceFilter(vec2 uv) {
        vec3 centerColor = texture2D(u_texture, uv).rgb;
        vec3 sum_up, sum_down;
        for(int k = -u_radius; k <= u_radius; k++) {
            for(int l = -u_radius; l <= u_radius; l++) {
                vec2 uv_new = uv + vec2(k, l)/u_resolution;
                vec3 curColor = texture2D(u_texture, uv_new).rgb;
                vec3 weight = CalculateWeight(curColor, centerColor);
                sum_up += weight * curColor;
                sum_down += weight;
            }
        }
        return vec4(sum_up / sum_down, 1.0);    
    }
    
    vec4 gaussian(vec2 uv, bool horizontalPass) {  
      float numBlurPixelsPerSide = float(blurSize / 2); 
    
      vec2 blurMultiplyVec = 0 < horizontalPass ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
     
      //高斯函數(shù)
      vec3 incrementalGaussian;
      incrementalGaussian.x = 1.0 / (sqrt(2.0 * pi) * sigma);
      incrementalGaussian.y = exp(-0.5 / (sigma * sigma));
      incrementalGaussian.z = incrementalGaussian.y * incrementalGaussian.y;
     
      vec4 avgValue = vec4(0.0, 0.0, 0.0, 0.0);
      float coefficientSum = 0.0;
     
      avgValue += texture2D(texture, vertTexCoord.st) * incrementalGaussian.x;
      coefficientSum += incrementalGaussian.x;
      incrementalGaussian.xy *= incrementalGaussian.yz;
     
      for (float i = 1.0; i <= numBlurPixelsPerSide; i++) { 
        avgValue += texture2D(texture, vertTexCoord.st - i * texOffset * 
                              blurMultiplyVec) * incrementalGaussian.x;         
        avgValue += texture2D(texture, vertTexCoord.st + i * texOffset * 
                              blurMultiplyVec) * incrementalGaussian.x;         
        coefficientSum += 2.0 * incrementalGaussian.x;
        incrementalGaussian.xy *= incrementalGaussian.yz;
      }
     
      return avgValue / coefficientSum;
    
    

比如雙邊濾波的效果

bilateral.png

高斯效果,分開縱橫兩個(gè)pass處理蒸甜,復(fù)雜度從WxHx(2R +1)x(2R +1) 降至 WxHx(2R + 1)

gaussian.png

suface blur 配合膚色檢測(cè)棠耕,可以看出邊緣部分有點(diǎn)硬,

surface.png
  1. 美白

HighPass高亮加一點(diǎn)紅暈

highpass.png
  1. 美型

美型主要是需要和人臉檢測(cè)結(jié)合起來柠新,對(duì)人臉各個(gè)部位進(jìn)行微調(diào)窍荧,比如:

  • 廋臉,根據(jù)AI找到的固定臉型的那幾個(gè)關(guān)鍵點(diǎn)恨憎,在各自方向做曲線變形處理
  • 大眼蕊退,找到中心點(diǎn),放大一定的半徑
  • 下巴框咙,也是做曲線變形處理

類似如下的曲線變形處理

// 曲線形變處理
vec2 curveWarp(vec2 textureCoord, vec2 originPosition, vec2 targetPosition, float radius)
{
    vec2 offset = vec2(0.0);
    vec2 result = vec2(0.0);

    vec2 direction = targetPosition - originPosition;

    float infect = distance(textureCoord, originPosition)/radius;

    infect = 1.0 - infect;
    infect = clamp(infect, 0.0, 1.0);
    offset = direction * infect;

    result = textureCoord - offset;

    return result;
}

......

? 這一塊主要是需要準(zhǔn)確的關(guān)鍵點(diǎn)處理咕痛,然后就是怎么調(diào)整曲線了,可以自己注冊(cè)一個(gè)Face++的FaceDetect自己玩一下喇嘱,包括怎么和貼紙配合茉贡,這一塊還在總結(jié)中,后續(xù)會(huì)自己研究(挖坑3)

3.3 轉(zhuǎn)場(chǎng)篇

? 轉(zhuǎn)場(chǎng)主要是涉及到兩段視頻之間者铜,Shader會(huì)有兩個(gè)Input腔丧,然后通過progress控制進(jìn)度放椰,我理解的轉(zhuǎn)場(chǎng)主要包括以下這些:

  1. UV變換

轉(zhuǎn)場(chǎng)很多其實(shí)都是UV變化,UV坐標(biāo)圍繞某個(gè)點(diǎn)旋轉(zhuǎn)愉粤、縮放砾医、平移

//scale center
vec2 scale_uv = vec2(0.5 + (tc.x - 0.5) / scaleU , 0.5 + (tc.y - 0.5) / scaleV );

//rotate
vec2 rotateUV(vec2 uv, float rotation, vec2 mid)
{
  float ratio = (resolution.x / resolution.y);
  float s = sin ( rotation );
  float c = cos ( rotation );
  mat2 rotationMatrix = mat2( c, -s, s, c);
  vec2 coord = vec2((uv.x - mid.x) * ratio ,(uv.y -mid.y)*1.0);
  vec2 scaled = rotationMatrix * coord;
  return vec2(scaled.x / ratio + mid.x,scaled.y + mid.y);
}

//translate
vec2 translate_uv = vec2(0.5 + (tc.x - 0.5) / scaleU , 0.5 + (tc.y - 0.5) / scaleV );
                

怎么控制進(jìn)度就慢慢調(diào)吧

  1. Blend轉(zhuǎn)場(chǎng)

首先熟悉一下PhotoShop里面的混合模式alphe混合、濾色衣厘、加深如蚜、減淡、高亮度等等

https://zhuanlan.zhihu.com/p/23905865

然后就可以慢慢玩了

  1. 模糊轉(zhuǎn)場(chǎng)

主要也是Photoshop里面的幾種模糊方式影暴,旋轉(zhuǎn)模糊错邦、高斯模糊、均值模糊等等

需要注意一下旋轉(zhuǎn)模糊型宙,我實(shí)現(xiàn)過一種旋轉(zhuǎn)模糊的轉(zhuǎn)場(chǎng)撬呢,需要配合隨機(jī)采樣

float rand(vec2 uv){
  return fract(sin(dot(uv.xy ,vec2(12.9898,78.233))) * 43758.5453);
}
vec4 rotation_blur(vec2 tc) {
  angle = angle * PI_ROTATION / 180.0;
  vec2 uv = tc;
  float uv_random = rand(uv);
  vec4 sum_color = vec4(0.0);
  for(float i = 0.0; i < samples; i++) {
    float percent = (i + uv_random) / samples;
    float real_angle = angle + percent * strength;
    real_angle = mod(real_angle, PI_ROTATION);
    vec2 uv_rotation = rotateUV(uv, real_angle, center);
    sum_color += INPUT(fract(uv_rotation));
  }
  return sum_color / samples;
}

And 這里有個(gè)轉(zhuǎn)場(chǎng)的網(wǎng)站,可以研究一下

https://gl-transitions.com/

3.4 特效篇

基礎(chǔ)的特效包括:抖動(dòng)妆兑、靈魂出竅魂拦、故障風(fēng)、光暈搁嗓、老電影芯勘、粒子特效等等

比如old film

<img src="https://i.loli.net/2020/03/30/GutfwzVTk2LlC1h.png" alt="old_film.png" style="zoom:50%;" />

比如glitch風(fēng)格(動(dòng)態(tài)的會(huì)好一點(diǎn))

<img src="https://i.loli.net/2020/03/30/sPNvIylF56gkYzQ.png" alt="glitch.png" style="zoom:50%;" />

?

? 不一而足...

?

? 這些特效都不難,網(wǎng)上Shader到處都是谱姓,基本copy下來調(diào)調(diào)參數(shù)就行借尿,可能也就粒子特效需要調(diào)很多細(xì)節(jié)刨晴,但如果你有圖形引擎的基礎(chǔ)屉来,這些都很簡(jiǎn)單,到時(shí)候我可以建個(gè)倉庫狈癞,share一些我自己實(shí)現(xiàn)的shader

一般特效的話可以先去網(wǎng)上找茄靠,比如shadertoy

https://www.shadertoy.com/

如果找不到的話,需要自己去想怎么實(shí)現(xiàn)蝶桶,我一般是會(huì)按如下的方式去思考:

  1. 善于利用各種卷積濾波器(邊緣檢測(cè)慨绳、模糊),有時(shí)候需要和隨機(jī)采樣結(jié)合
  2. 熟悉各種顏色空間真竖,熟悉飽和度脐雪、銳度、亮度恢共、色度等基礎(chǔ)調(diào)節(jié)
  3. UV變換多寫幾個(gè)有經(jīng)驗(yàn)了战秋,然后理解一些曲線函數(shù),基本都能慢慢調(diào)出來
  4. 可以試著看看相關(guān)論文讨韭,或者OpenCV的實(shí)現(xiàn)方式

簡(jiǎn)單的特效實(shí)現(xiàn)起來并不難脂信,多去寫癣蟋,慢慢總結(jié)經(jīng)驗(yàn)的套路,如果更深入一點(diǎn)可能需要圖形圖像和數(shù)學(xué)的知識(shí)

3.5 串聯(lián)這些效果

? 可能大家比較熟悉的就是GPUImage了狰闪,基本都是用這個(gè)去串聯(lián)這些特效疯搅、濾鏡之類,關(guān)于GPUImage網(wǎng)上也有很多資料埋泵,這里就不具體講了幔欧,也比較簡(jiǎn)單,內(nèi)部就是一個(gè)Input一個(gè)output通過FBO串起來丽声。這里主要想推薦大家看下movit這個(gè)框架琐馆,基本跟GPUImage類似,支持單個(gè)input和多個(gè)input恒序,可以打斷中間節(jié)點(diǎn)瘦麸,也可以有FrameBufferCache機(jī)制,但他還有一個(gè)優(yōu)化的點(diǎn)就是歧胁,Shader動(dòng)態(tài)組裝機(jī)制滋饲,熟悉游戲引擎應(yīng)該都知道這個(gè),Shader是可以在最后動(dòng)態(tài)生成喊巍,他里面是通過宏define來控制屠缭,可以把一些列串聯(lián)特效組裝到一起,非常高效崭参,后面可以單獨(dú)講講這塊呵曹。

只需要看下他組裝shader的過程:

for (unsigned i = 0; i < phase->effects.size(); ++i) {
    Node *node = phase->effects[i];
    const string effect_id = phase->effect_ids[make_pair(node, IN_SAME_PHASE)];
    for (unsigned j = 0; j < node->incoming_links.size(); ++j) {
        if (node->incoming_links.size() == 1) {
            frag_shader += "#define INPUT";
        } else {
            char buf[256];
            sprintf(buf, "#define INPUT%d", j + 1);
            frag_shader += buf;
        }

        Node *input = node->incoming_links[j];
        NodeLinkType link_type = node->incoming_link_type[j];
        if (i != 0 &&
            input->effect->is_compute_shader() &&
            node->incoming_link_type[j] == IN_SAME_PHASE) {
            // First effect after the compute shader reads the value
            // that cs_output() wrote to a global variable,
            // ignoring the tc (since all such effects have to be
            // strong one-to-one).
            frag_shader += "(tc) CS_OUTPUT_VAL\n";
        } else {
            assert(phase->effect_ids.count(make_pair(input, link_type)));
            frag_shader += string(" ") + phase->effect_ids[make_pair(input, link_type)] + "\n";
        }
    }

    frag_shader += "\n";
    frag_shader += string("#define FUNCNAME ") + effect_id + "\n";
    if (node->effect->is_compute_shader()) {
        frag_shader += string("#define NORMALIZE_TEXTURE_COORDS(tc) ((tc) * ") + effect_id + "_inv_output_size + " + effect_id + "_output_texcoord_adjust)\n";
    }
    frag_shader += replace_prefix(node->effect->output_fragment_shader(), effect_id);
    frag_shader += "#undef FUNCNAME\n";
    if (node->incoming_links.size() == 1) {
        frag_shader += "#undef INPUT\n";
    } else {
        for (unsigned j = 0; j < node->incoming_links.size(); ++j) {
            char buf[256];
            sprintf(buf, "#undef INPUT%d\n", j + 1);
            frag_shader += buf;
        }
    }
    frag_shader += "\n";
}

完了之后可以生成類似如下的shader:

precision highp float;
varying vec2 tc;

#define FUNCNAME eff0
uniform sampler2D eff0_tex;
vec4 FUNCNAME(vec2 tc) {
    return texture2D(eff0_tex, vec2(tc.x,1.0-tc.y));
}
#undef PREFIX
#undef FUNCNAME

#define INPUT eff0

#define FUNCNAME eff1
uniform float eff1_strength;
uniform sampler2D eff1_lut;
vec4 FUNCNAME(vec2 tc) { 
    float strength = eff1_strength;
    lowp vec4 textureColor = INPUT(tc); 
    mediump float blueColor = textureColor.b * 63.0;
    mediump vec2 quad1; quad1.y = floor(floor(blueColor) / 8.0); 
    quad1.x = floor(blueColor) - (quad1.y * 8.0); 
    mediump vec2 quad2; 
    quad2.y = floor(ceil(blueColor) / 8.0); 
    quad2.x = ceil(blueColor) - (quad2.y * 8.0); 
    highp vec2 texPos1; 
    texPos1.x = (quad1.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r); 
    texPos1.y = (quad1.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);
    highp vec2 texPos2; 
    texPos2.x = (quad2.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
    texPos2.y = (quad2.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);
    lowp vec4 newColor1 = texture2D(eff1_lut, texPos1); 
    lowp vec4 newColor2 = texture2D(eff1_lut, texPos2); 
    lowp vec4 newColor = mix(newColor1, newColor2, fract(blueColor)); 
    return mix(textureColor, vec4(newColor.rgb, textureColor.w), strength);
} 
#undef PREFIX
#undef FUNCNAME
#undef INPUT

#define INPUT eff1
void main()
{
    gl_FragColor = INPUT(tc);
}

? 直接通過一個(gè)#define FUNCNAME就可以串起來,我以前寫的那個(gè)渲染引擎何暮,也是動(dòng)態(tài)組裝shader奄喂,跟這個(gè)有點(diǎn)類似,不過比這個(gè)復(fù)雜海洼,需要更多的宏來控制跨新,有興趣可以去看看

Movit源碼:https://git.sesse.net/?p=movit;a=summary

4. 關(guān)于音視頻處理

4.1 音視頻理論知識(shí)

  1. H264/H265編碼原理,宏快怎么劃分
  2. I坏逢、P域帐、B幀壓縮方式
  3. SPS/PPS 信息
  4. 音頻的采樣率
  5. 封裝格式(MP4, FLV),MP4的Box形式存儲(chǔ)
  6. YUV數(shù)據(jù)

4.2 編解碼部分

音視頻解碼基本流程

codec.jpeg

參考:

https://blog.csdn.net/leixiaohua1020/article/details/18893769

  1. 硬解部分(Android MediaCodec)

在低端平臺(tái)更多的需要依賴硬件解碼是整,效率會(huì)更高肖揣,Android MediaCodec

mediacodec.png

https://developer.android.com/reference/android/media/MediaCodec?hl=en

大概的代碼邏輯

while (!m_sawOutputEOS) {
            if (!m_sawInputEOS) {
                // Feed more data to the decoder
                final int inputBufIndex = m_decoder.dequeueInputBuffer(TIMEOUT_USEC);
                if (inputBufIndex >= 0) {
                    ByteBuffer inputBuf = m_decoderInputBuffers[inputBufIndex];
                    // Read the sample data into the ByteBuffer. This neither
                    // respects nor
                    // updates inputBuf's position, limit, etc.
                    final int chunkSize = m_extractor.readSampleData(inputBuf, 0);

                    if (m_verbose)
                        Log.d(TAG, "input packet length: " + chunkSize + " time stamp: " + m_extractor.getSampleTime());

                    if (chunkSize < 0) {
                        // End of stream -- send empty frame with EOS flag set.
                        m_decoder.queueInputBuffer(inputBufIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
                        m_sawInputEOS = true;
                        if (m_verbose)
                            Log.d(TAG, "Sent input EOS");
                    } else {
                        if (m_extractor.getSampleTrackIndex() != m_videoTrackIndex) {
                            Log.w(TAG, "WEIRD: got sample from track " + m_extractor.getSampleTrackIndex()
                                    + ", expected " + m_videoTrackIndex);
                        }

                        long presentationTimeUs = m_extractor.getSampleTime();

                        m_decoder.queueInputBuffer(inputBufIndex, 0, chunkSize, presentationTimeUs, 0);
                        if (m_verbose)
                            Log.d(TAG,
                                    "Submitted frame to decoder input buffer " + inputBufIndex + ", size=" + chunkSize);

                        m_inputBufferQueued = true;
                        ++m_pendingInputFrameCount;
                        if (m_verbose)
                            Log.d(TAG, "Pending input frame count increased: " + m_pendingInputFrameCount);

                        m_extractor.advance();
                        m_extractorInOriginalState = false;
                    }
                } else {
                    if (m_verbose)
                        Log.d(TAG, "Input buffer not available");
                }
            }
  final int decoderStatus = m_decoder.dequeueOutputBuffer(m_bufferInfo, dequeueTimeoutUs);
    if (decoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
                // No output available yet
                if (m_verbose)
                    Log.d(TAG, "No output from decoder available");
            } else if (decoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
                // Not important for us, since we're using Surface
                if (m_verbose)
                    Log.d(TAG, "Decoder output buffers changed");
            } else if (decoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                MediaFormat newFormat = m_decoder.getOutputFormat();
                if (m_verbose)
                    Log.d(TAG, "Decoder output format changed: " + newFormat);
            } else if (decoderStatus < 0) {
                Log.e(TAG, "Unexpected result from decoder.dequeueOutputBuffer: " + decoderStatus);
                return ERROR_FAIL;
            } else {
            ///....
        m_decoder.releaseOutputBuffer(decoderStatus, doRender);
        }
    }

解碼過程網(wǎng)上到處都是,解碼主要就是配合MediaExtrator浮入,直接說一下需要注意的點(diǎn):

  • getOutputBuffer得到的數(shù)據(jù)格式主要是YUV420P和YUV420SP龙优,UV通道排列不一樣
  • seekTo完之后要Flush
  • configure如果傳入surface則是直接decode到suface上,不同通過getOutputBuffer獲取數(shù)據(jù)
  1. 軟解部分(FFMPEG)

? 其實(shí)現(xiàn)在絕大部分音視頻APP編解碼都是用的FFMPEG舵盈,這里門也包含了硬解MediaCodec部分陋率,所以只需要用FFMPEG就可以做硬解和軟解的切換球化。關(guān)于FFMPEG的學(xué)習(xí),首推肯定是雷神的博客啦瓦糟,

https://blog.csdn.net/leixiaohua1020/article/details/15811977

主要包括兩個(gè)部分筒愚,首先是prepare部分,

if (avformat_open_input(&mFormatContext, path.c_str(), NULL, NULL) != 0) {
        mlt_log_error(mProducer, "Could not open input file: %s", path.c_str());
        goto error;
    }

if (avformat_find_stream_info(mFormatContext, NULL) < 0) {
  mlt_log_error(mProducer, "Could not find stream information");
  goto error;
}

mVideoStreamIndex = findPreferedVideoStream();//遍歷track找到視頻軌
mVideoStream = mFormatContext->streams[mVideoStreamIndex];

//然后就可以得到一些列參數(shù)菩浙,width巢掺,height,fps劲蜻,fromat


//如果是硬解
//根據(jù)不同的編碼器陆淀,找到對(duì)應(yīng)的解碼器,如H264先嬉,H265等
mVideoCodec = avcodec_find_decoder_by_name("h264_mediacodec");;

//如果是軟解
mVideoCodec = avcodec_find_decoder(mVideoStream->codecpar->codec_id);

//open
avcodec_open2(mVideoCodecContext, mVideoCodec, NULL)
//就可以開始解碼了

解碼部分

do {
        if (!mPacket) {
            ret = getVideoPacket();
            if (ret < 0 && ret != AVERROR_EOF) {
                break;
            }
        }
                //這里面送包的時(shí)候有很多細(xì)節(jié)
            int ret = avcodec_send_packet(mVideoCodecContext, flush ? NULL : mPacket);
            if (ret >= 0)
            freeVideoPacket();
        else if (ret < 0 && ret != AVERROR_EOF && ret != AVERROR(EAGAIN)) {
            break;
        }
        ret = avcodec_receive_frame(mVideoCodecContext, mFrame);
        if (ret >= 0) {

            ret = DECODER_FFMPEG_SUCCESS;
            //計(jì)算pts
            mCurrentPos = getTime(mFrame->pts == AV_NOPTS_VALUE ? mFrame->pkt_dts : mFrame->pts);
            pts = mCurrentPos;
            
            break;
        }

        ++count;
    } while (ret < DECODER_FFMPEG_SUCCESS && count < DECODER_TRY_ATTEMPTS);

//后面就是針對(duì)不同的格式轧苫,接入數(shù)據(jù),
//還可以通過swscaleFrame進(jìn)行轉(zhuǎn)碼疫蔓,轉(zhuǎn)成你想要的格式

//然后就是copy AVFrame里面的數(shù)據(jù)啦

關(guān)于FFMPEG有太多可以去學(xué)習(xí)的了含懊,比如:

  • 學(xué)會(huì)用ffmpeg ffprobe ffplay一些命令,真的非常強(qiáng)大衅胀,方便分析問題(ffprobe -i xxxx)
  • 怎么去切換軟解和硬碼
  • 熟悉API返回的狀態(tài)

然后就是兼容性的問題

? 由于Android平臺(tái)太復(fù)雜了岔乔,廠商很多,所以會(huì)有無窮無盡的兼容性問題需要去解決滚躯,總體來說兼容性問題主要包括:

  • 數(shù)據(jù)源的兼容性雏门,YUV格式非常多樣,420p/420sp是常見的掸掏,還有yuv420p10le 這種10bit茁影,賊坑
  • 硬件平臺(tái)的兼容性,這里坑就更多了,MediaCodec支持程度呜叫,尤其是低端機(jī)非常需要注意
  • 分辨率問題,4K/8K,高通平臺(tái)的支持程度

這個(gè)后續(xù)單獨(dú)去整理吧......(挖坑4)

4.3 音視頻同步

視音頻同步的實(shí)現(xiàn)方式其實(shí)有三種顶滩,分別是:

  • 以音頻為主時(shí)間軸作為同步源;
  • 以視頻為主時(shí)間軸作為同步源;
  • 以外部時(shí)鐘為主時(shí)間軸作為同步源;

? 具體用哪一種需要根據(jù)場(chǎng)景,比如在線視頻播放器苦始,一般會(huì)以第一種音頻作為主時(shí)間軸去對(duì)齊視頻幀數(shù)據(jù)坎匿,做丟幀和用當(dāng)前幀處理,比如我之前寫的音視頻編輯SDK蚁廓,因?yàn)槲覀冇幸粭l固定幀率的時(shí)間線访圃,所以我們的對(duì)齊方式是以這條固定時(shí)間軸來對(duì)齊視頻。

? 比如當(dāng)前時(shí)間軸如果大于當(dāng)前decode出來的幀的pts相嵌,就直接丟掉腿时,繼續(xù)找下一幀况脆,如果當(dāng)前時(shí)間軸小于decode出來的幀的pts,超過1一幀的時(shí)間就繼續(xù)用上一幀渲染即可批糟。

4.4 多視頻多軌道(進(jìn)階)

multi_track.png

? 視頻編輯SDK肯定是需要支持多視頻多軌道編輯格了,如何高效的管理,方便編輯和預(yù)覽徽鼎,這里面主要是需要一個(gè)好的Multi track的框架支撐盛末,在這里推薦一個(gè)MLT框架,這個(gè)框架對(duì)于多視頻多軌道處理真的非常強(qiáng)大否淤,內(nèi)部有精確的時(shí)間戳對(duì)齊邏輯悄但,只需要關(guān)注多軌道數(shù)據(jù)直接的排列,包括每一個(gè)視頻片段生產(chǎn)數(shù)據(jù)石抡,其內(nèi)部會(huì)幫我們做好時(shí)間戳對(duì)齊檐嚣,擔(dān)任也可以和很多插件配合使用,比如movit啰扛,ffmpeg等净嘀。
? 基本框架

+--------+   +------+   +--------+
|Producer|-->|Filter|-->|Consumer|
+--------+   +------+   +--------+

具體的介紹后面單獨(dú)寫一篇博客整理,如下是官網(wǎng)介紹:

https://github.com/mltframework/mlt

5. 關(guān)于傳輸

這塊沒做過侠讯,對(duì)傳輸協(xié)議不太了解挖藏,只知道一些理論知識(shí),比如RTMP的分塊傳輸厢漩,后面有機(jī)會(huì)再補(bǔ)齊

5.1 RTMP

6. 分析音視頻APP

? 在開發(fā)過程中膜眠,對(duì)于一個(gè)小白來說可能會(huì)經(jīng)常遇到一籌莫展的時(shí)候,這時(shí)候要學(xué)會(huì)向同類優(yōu)秀的應(yīng)用學(xué)習(xí)溜嗜,比如做短視頻相關(guān)的可以去看抖音/快手怎么做的宵膨,做音視頻剪輯相關(guān)的可以看看見剪映/快影/小影怎么做的,你可以去把他們的包pull出來炸宵,比如我在做資源的接入的時(shí)候辟躏,就對(duì)比過快手/抖音/大疆 這幾家的資源,看他們的sticker怎樣接入土全,有png序列捎琐,有MP4,有自定義GIF格式的裹匙。所以從這些APP的packge內(nèi)部還是能找到一些線索瑞凑,跟著這些線索再慢慢找到其內(nèi)部的邏輯,就比如我上面分析抖音潛水艇那個(gè)游戲一樣概页,當(dāng)然也可以去分析其它的一些文件籽御,給你提供思路。

1. 抖音

Package name: com.ss.android.ugc.aweme

adb pull /data/data/com.ss.android.ugc.aweme

tiktok_package.png

主要是分析了一下里面的Effect資源,還有LOG信息, 后面可以有機(jī)會(huì)繼續(xù)拆解一下(挖坑5)

2. 快手

Package name: com.smile.gifmaker

同樣的方式

3. 大疆

Package name: dji.mimo

? 主要是DJI mimo技掏,但是做的比較爛铃将,所以沒有太多可分析的,不過通過他們的sticker資源可以看到他們支持alpha通道視頻的原理哑梳,后面有類似需求也可以拿過來用

7. 總結(jié)

現(xiàn)在是凌晨3點(diǎn)多劲阎,從周日從早上開始寫到晚上3點(diǎn),差不多用了將近18個(gè)小時(shí)來整理[吐血]涧衙。想想上一次博客更新差不多是兩年前了哪工,那時(shí)候是做圖形渲染相關(guān)的事情,也試著自己寫了一個(gè)渲染引擎弧哎,結(jié)合了不錯(cuò)的開源引擎雁比,寫完之后對(duì)我圖形方向的理解有很大的進(jìn)步,于是試著開始寫了幾篇博客撤嫩,繼續(xù)維護(hù)自己關(guān)于圖形方向的研究偎捎,后面由于各種原因,沒有繼續(xù)在維護(hù)之前的圖形引擎序攘,自己也不在更新博客了茴她。

? 今天以音視頻作為新的方向,重新回來程奠,把自己這兩年的一些積累整理出來丈牢,希望能對(duì)后面的學(xué)習(xí)者有所幫助,我也會(huì)盡力去打磨好每一篇博客瞄沙,今天這遍博客只是總結(jié)一個(gè)大的框架己沛,里面有非常非常多的細(xì)節(jié),都可單獨(dú)用一篇文章去講距境,比如關(guān)于Camera申尼,音視頻特效,編解碼垫桂,兼容性這些师幕,都需要花時(shí)間去一點(diǎn)一點(diǎn)研究。我也會(huì)對(duì)自己每一篇文章有嚴(yán)格的要求诬滩,要么不寫霹粥,要寫就盡量按照論文的形式把每一個(gè)點(diǎn)講清楚,結(jié)合流程圖和效果圖碱呼,最后還需要給出Demo復(fù)現(xiàn)效果蒙挑。

? 還是開頭那句話,隨著5G是時(shí)代的到來愚臀,音視頻的應(yīng)用將會(huì)更加普及,這方面的人才也會(huì)更加緊缺,同時(shí)對(duì)于這里面的技術(shù)要求也會(huì)越來越高姑裂,從現(xiàn)在開始就慢慢積累馋袜,完善里面的技能棧,也可以選擇一個(gè)分支去深入下去舶斧,這里面有很多的方向都值得研究欣鳖。也希望以這篇文章為起點(diǎn),尋找一些志同道合之人茴厉,一起去探索一些音視頻方向的玩法泽台,當(dāng)然還有相機(jī)這塊,因?yàn)槲抑耙恢笔亲鱿鄼C(jī)相關(guān)的矾缓,也思考過相機(jī)有什么好的方向可以去探索怀酷。

? 總之,我會(huì)一直做音視頻這個(gè)方向嗜闻,抖音和快手在視頻玩法上做到了極致蜕依,手機(jī)廠商不斷的升級(jí)Camera,也越來越重視視頻的采集琉雳,讓手機(jī)作為拍攝工具可以拍出更加震撼的效果样眠,還有大疆在無人機(jī)領(lǐng)域視角去采集的視頻數(shù)據(jù),也可以做出很多大片效果翠肘,還有Insta360在全景方向的玩法檐束,做的也很棒,未來隨著硬件設(shè)備的升級(jí)束倍,還會(huì)出現(xiàn)更多有趣的玩法被丧,比如和AR/VR結(jié)合在一起,又會(huì)有怎樣的火花呢肌幽,我們拭目以待晚碾。

BLOG: http://yanglusheng.com/

人生只有一次,做自己喜歡的事吧

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
禁止轉(zhuǎn)載喂急,如需轉(zhuǎn)載請(qǐng)通過簡(jiǎn)信或評(píng)論聯(lián)系作者格嘁。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市廊移,隨后出現(xiàn)的幾起案子糕簿,更是在濱河造成了極大的恐慌,老刑警劉巖狡孔,帶你破解...
    沈念sama閱讀 206,214評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件懂诗,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡苗膝,警方通過查閱死者的電腦和手機(jī)殃恒,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人离唐,你說我怎么就攤上這事病附。” “怎么了亥鬓?”我有些...
    開封第一講書人閱讀 152,543評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵完沪,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我嵌戈,道長(zhǎng)覆积,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,221評(píng)論 1 279
  • 正文 為了忘掉前任熟呛,我火速辦了婚禮宽档,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘惰拱。我一直安慰自己雌贱,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評(píng)論 5 371
  • 文/花漫 我一把揭開白布偿短。 她就那樣靜靜地躺著欣孤,像睡著了一般。 火紅的嫁衣襯著肌膚如雪昔逗。 梳的紋絲不亂的頭發(fā)上降传,一...
    開封第一講書人閱讀 49,007評(píng)論 1 284
  • 那天,我揣著相機(jī)與錄音勾怒,去河邊找鬼婆排。 笑死,一個(gè)胖子當(dāng)著我的面吹牛笔链,可吹牛的內(nèi)容都是我干的段只。 我是一名探鬼主播,決...
    沈念sama閱讀 38,313評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼鉴扫,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼赞枕!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起坪创,我...
    開封第一講書人閱讀 36,956評(píng)論 0 259
  • 序言:老撾萬榮一對(duì)情侶失蹤炕婶,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后莱预,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體柠掂,經(jīng)...
    沈念sama閱讀 43,441評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評(píng)論 2 323
  • 正文 我和宋清朗相戀三年依沮,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了涯贞。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片枪狂。...
    茶點(diǎn)故事閱讀 38,018評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖肩狂,靈堂內(nèi)的尸體忽然破棺而出摘完,到底是詐尸還是另有隱情姥饰,我是刑警寧澤傻谁,帶...
    沈念sama閱讀 33,685評(píng)論 4 322
  • 正文 年R本政府宣布,位于F島的核電站列粪,受9級(jí)特大地震影響审磁,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜岂座,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評(píng)論 3 307
  • 文/蒙蒙 一态蒂、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧费什,春花似錦钾恢、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至稿黍,卻和暖如春疹瘦,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背巡球。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評(píng)論 1 261
  • 我被黑心中介騙來泰國(guó)打工言沐, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人酣栈。 一個(gè)月前我還...
    沈念sama閱讀 45,467評(píng)論 2 352
  • 正文 我出身青樓险胰,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親矿筝。 傳聞我的和親對(duì)象是個(gè)殘疾皇子起便,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容